Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b3728abd | ||
|
|
e1659069b1 | ||
|
|
4b35eb52cb | ||
|
|
05835675b6 | ||
|
|
a1a3a021ef | ||
|
|
40e21d3c07 | ||
|
|
46932c46ca | ||
|
|
9e0a40600b | ||
|
|
d54da54033 | ||
|
|
33152a7f52 | ||
|
|
3dd622b5a5 | ||
|
|
f73052cd5c | ||
|
|
1b69e89718 | ||
|
|
f3c00d7294 | ||
|
|
80e3f65cd6 | ||
|
|
0cf769ef67 | ||
|
|
5e53203a61 | ||
|
|
cf5afdf4c0 | ||
|
|
b6e714e3fc | ||
|
|
0022adf958 | ||
|
|
22888c3d53 | ||
|
|
c2c9eb8d81 | ||
|
|
6df9c92020 | ||
|
|
6bb8c530a4 | ||
|
|
024143efa5 | ||
|
|
06c6374126 | ||
|
|
82177403f6 | ||
|
|
ef074a0c97 | ||
|
|
00fd375e4c | ||
|
|
5b0c3eb404 | ||
|
|
3161470bd2 | ||
|
|
1852701236 | ||
|
|
21fc8c60ed | ||
|
|
77395fa9a0 | ||
|
|
490ed4f65a | ||
|
|
d6d2407a0d | ||
|
|
db3fcb89a8 | ||
|
|
20b7a41104 | ||
|
|
e739e67052 | ||
|
|
1e6c7b9ad8 | ||
|
|
1afbb11d3b | ||
|
|
850beec8d7 | ||
|
|
fb11c71ec0 | ||
|
|
8acdd104bb | ||
|
|
43d77e520f | ||
|
|
a3f731d01c | ||
|
|
8dba5b8c2e | ||
|
|
74158908e8 | ||
|
|
ab36e36d5d | ||
|
|
c1b14753a3 | ||
|
|
c62c8ee9f6 | ||
|
|
9626e66e5b | ||
|
|
b68e3c85a3 | ||
|
|
74014514da | ||
|
|
4e0e6dcb90 | ||
|
|
0778a31ec0 | ||
|
|
80c4cbde03 | ||
|
|
fb739f6e8f | ||
|
|
e6da39a3cd | ||
|
|
95d2e52cb5 | ||
|
|
283dec58ab | ||
|
|
7de1fe80ac | ||
|
|
89a502b39b | ||
|
|
61bec280e2 | ||
|
|
1791c91c5b | ||
|
|
e8c0f0250b | ||
|
|
94718308fc | ||
|
|
a4cbed0f5f | ||
|
|
25c7740739 | ||
|
|
8bbb049c39 | ||
|
|
1707cc4953 | ||
|
|
e397594200 | ||
|
|
68b7f2c1d2 | ||
|
|
fda98e61f5 | ||
|
|
ea9f599522 | ||
|
|
1aabcd8b62 | ||
|
|
4d68af9524 | ||
|
|
467b127804 | ||
|
|
50a73b211b | ||
|
|
f14d4b6e14 | ||
|
|
fc8bd8c8d4 | ||
|
|
382fe662a3 | ||
|
|
4754e5762a | ||
|
|
ce7d35a811 | ||
|
|
60ac7d9d0b | ||
|
|
2a10d1cdd9 | ||
|
|
677ea738e7 | ||
|
|
7154d62cca | ||
|
|
4e154c8baf | ||
|
|
b848361079 | ||
|
|
74224cfbd5 | ||
|
|
a7c09ba646 | ||
|
|
f055cb74bb | ||
|
|
071a378de6 | ||
|
|
a492b15df6 | ||
|
|
a9f0a4359b | ||
|
|
6a9fab894a | ||
|
|
e9111dea47 | ||
|
|
d25fbfa61e | ||
|
|
6b03c47625 | ||
|
|
770fe1ef66 | ||
|
|
7a025430f9 | ||
|
|
d079da4029 | ||
|
|
b8f7dc4112 | ||
|
|
947cdaff42 | ||
|
|
83b0c8bd74 | ||
|
|
fbd880cf8c | ||
|
|
819ac94c9f | ||
|
|
b02401e60d | ||
|
|
cbb2e232f5 | ||
|
|
9e46ac0504 |
@@ -0,0 +1,3 @@
|
||||
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
|
||||
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
|
||||
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": "aios.evidence.manifest.v1",
|
||||
"generated_at": "2026-07-21T05:50:00Z",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
|
||||
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
|
||||
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
|
||||
"evidence_hashes": {
|
||||
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
|
||||
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
|
||||
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
|
||||
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
|
||||
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
|
||||
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
|
||||
},
|
||||
"validation": {
|
||||
"candidate_tests": {"passed": 49, "failed": 0},
|
||||
"hermes_lifecycle": {"passed": 17, "failed": 0},
|
||||
"gate_b": {"passed": 10, "failed": 0},
|
||||
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
|
||||
"openclaw_real_http": "PASS",
|
||||
"restart_recovery": "PASS",
|
||||
"rollback_forward": "PASS",
|
||||
"state_preservation": "PASS",
|
||||
"final_health_corrected_16R": "PASS"
|
||||
},
|
||||
"git_gate": {
|
||||
"oid_match": true,
|
||||
"candidate_clean": true,
|
||||
"active_controller_clean": true,
|
||||
"locked": 0,
|
||||
"prunable": 0,
|
||||
"worktree_total": 88,
|
||||
"worktree_dirty": 10,
|
||||
"global_worktree_clean": false
|
||||
},
|
||||
"resolved_blockers": [],
|
||||
"active_blockers": [
|
||||
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
|
||||
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
|
||||
],
|
||||
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
|
||||
"release_decision": "STOP_PREPRODUCTION",
|
||||
"secrets_recorded": false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# AIOS V2R1 Hermes Lifecycle Result
|
||||
|
||||
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
|
||||
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||
- Release decision: `STOP_PREPRODUCTION`
|
||||
|
||||
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
|
||||
|
||||
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
|
||||
|
||||
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
|
||||
|
||||
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# V2R1C1 Cleanup Closure
|
||||
|
||||
- `CLEANUP_REVIEW=PASS`
|
||||
- `ARCHIVE_VALIDATION=PASS`
|
||||
- `GLOBAL_WORKTREE_CLEAN=true`
|
||||
- `global_dirty_count=0`
|
||||
- `locked=0`
|
||||
- `prunable=0`
|
||||
- Portal Archive OID: `99671cc14d2631f989e0763dc882e4001c50a1ea`
|
||||
- Spark Archive OID: `d97cd3642528bd3373fe4cf5161a5d99c0b9285f`
|
||||
|
||||
Closure evidence: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-blocker-clear-20260721T075647Z`.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": "aios.v2r1c1.deployment-manifest.v1",
|
||||
"vrc": "V2R1C1",
|
||||
"release_branch": "release/AIOS-V2R1C1-20260721",
|
||||
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
|
||||
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
|
||||
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"companion": {
|
||||
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||
"plugin_version": "2.1.0-rc.4",
|
||||
"contract_version": "aios.openclaw.command.v2",
|
||||
"signature_algorithm": "Ed25519",
|
||||
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
|
||||
},
|
||||
"isolated_validation": {
|
||||
"service": "aios-v2r1c1-openclaw-companion.service",
|
||||
"account": "aios-v2r1c1-openclaw",
|
||||
"listen": "127.0.0.1:28789",
|
||||
"accept_v2": true,
|
||||
"accept_v1": false,
|
||||
"public_key_only": true,
|
||||
"production_traffic_switched": false,
|
||||
"result": "PASS"
|
||||
},
|
||||
"previous_plugin": {
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"plugin_version": "2.1.0-rc.3",
|
||||
"artifact_sha256": "d0fd11be4aec03eee9ca24bf1585d9ef3597254ad4b1db0d0137eab4456b5737"
|
||||
},
|
||||
"rollback": {
|
||||
"production_plugin_unchanged": true,
|
||||
"failed_isolated_attempts_rolled_back": true,
|
||||
"previous_plugin_evidence": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z/00-baseline",
|
||||
"previous_oid": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||
"isolated_rollback_forward": "PASS",
|
||||
"production_symlink_modified": false
|
||||
},
|
||||
"release_validation": {
|
||||
"gate_a": "PASS",
|
||||
"gate_b": "PASS",
|
||||
"openclaw_v2_live": "PASS",
|
||||
"openclaw_negative_live": "PASS",
|
||||
"hermes_live": "PASS",
|
||||
"restart_recovery": "PASS",
|
||||
"rollback_forward": "PASS",
|
||||
"final_health": "PASS",
|
||||
"secret_violations": 0,
|
||||
"global_dirty_count": 0,
|
||||
"sync_status": "fully_synced"
|
||||
},
|
||||
"tag_status": "ABSENT",
|
||||
"preproduction_status": "NOT_STARTED",
|
||||
"production_status": "NOT_STARTED"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": "aios.v2r1c1.release-evidence.v2",
|
||||
"vrc": "V2R1C1",
|
||||
"semver": "2.1.0-rc.1",
|
||||
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
|
||||
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
|
||||
"release_branch": "release/AIOS-V2R1C1-20260721",
|
||||
"release_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721",
|
||||
"companion_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
|
||||
"openclaw_companion": {
|
||||
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||
"plugin_version": "2.1.0-rc.4",
|
||||
"contract_version": "aios.openclaw.command.v2",
|
||||
"signature_algorithm": "Ed25519",
|
||||
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
|
||||
},
|
||||
"step13": {
|
||||
"original_result": "FAIL_HTTP_401",
|
||||
"original_diagnosis_manifest_sha256": "320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f",
|
||||
"remediated_result": "PASS",
|
||||
"online_negative_evidence_sha256": "57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f",
|
||||
"remediated_evidence_sha256": "5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130",
|
||||
"task_state": "COMPLETED",
|
||||
"dispatch_state": "COMPLETED",
|
||||
"task_dispatch_audit_consistency": "PASS"
|
||||
},
|
||||
"release_validation": {
|
||||
"static_revalidation": "PASS",
|
||||
"static_evidence_sha256": "c33ffaf9a0f509d6e78bdfdc0848940c166b8a800940e259b7196adab04ec604",
|
||||
"gate_a": "PASS",
|
||||
"gate_b": "PASS",
|
||||
"documentation_sync": "PASS",
|
||||
"secret_scan_violations": 0,
|
||||
"release_metadata": "PASS",
|
||||
"hermes_live": "PASS",
|
||||
"hermes_evidence_sha256": "e6553c99db55c3cdf9e16a4c1e6712064e00c41dc9965d0d179b6b1a2a1ad3ac",
|
||||
"restart_recovery": "PASS",
|
||||
"restart_evidence_sha256": "6031c92cd890dffd1de28ba75d81bed908e7da11a24aa129bcfde8efb23f0149",
|
||||
"rollback_forward": "PASS",
|
||||
"rollback_evidence_sha256": "f3d49c54e3f722facf68df2da3d82a9c82eb576f2c7c8dacb0f62c424d25737c",
|
||||
"final_health": "PASS",
|
||||
"final_health_evidence_sha256": "121cd52c0a4852b818e06be69211ae18b9fcccf976fa064e2a09c54eb8a01300",
|
||||
"global_dirty_count": 0,
|
||||
"sync_status": "fully_synced"
|
||||
},
|
||||
"validation_status": "PASS",
|
||||
"tag_status": "ABSENT",
|
||||
"preproduction_status": "NOT_STARTED",
|
||||
"production_status": "NOT_STARTED",
|
||||
"secrets_recorded": false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# V2R1C1 Validation Result
|
||||
|
||||
Status: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
|
||||
|
||||
Release decision: `STOP_PREPRODUCTION`
|
||||
|
||||
## OpenClaw Command v2 Companion
|
||||
|
||||
- Source OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
|
||||
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
|
||||
- Plugin version: `2.1.0-rc.4`
|
||||
- Contract: `aios.openclaw.command.v2`
|
||||
- Signature: `Ed25519`
|
||||
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
|
||||
- Companion unit/contract tests: `PASS`
|
||||
- V1 fallback: `REJECTED`
|
||||
- Isolated Gateway health: `PASS`
|
||||
- Online positive/negative contract: `PASS`
|
||||
- AIOS -> OpenClaw real HTTP: `PASS`
|
||||
- Task state: `COMPLETED`
|
||||
- Dispatch state: `COMPLETED`
|
||||
- Task/Dispatch/Audit consistency: `PASS`
|
||||
|
||||
## Evidence linkage
|
||||
|
||||
- Original failed Release OID: `80c4cbde03a82c30648a09b817bc2f03d5402936`
|
||||
- Original HTTP 401 diagnosis: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721/pass1-80c4cbde/13-diagnosis`
|
||||
- Original manifest SHA256: `320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f`
|
||||
- Companion evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
|
||||
- Validated Release parent OID: `0778a31ec073e7c90003ef091f1d093c16bf22e5`
|
||||
- Online negative evidence SHA256: `57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f`
|
||||
- Remediated Step 13 evidence SHA256: `5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130`
|
||||
|
||||
## Release validation closure
|
||||
|
||||
- Gate A: `PASS`
|
||||
- Gate B: `PASS`
|
||||
- Documentation sync: `PASS`
|
||||
- Secret violations: `0`
|
||||
- Release Metadata: `PASS`
|
||||
- Hermes live: `PASS`
|
||||
- Restart recovery: `PASS`
|
||||
- Previous ↔ V2R1C1 isolated rollback/forward: `PASS`
|
||||
- Final health: `PASS`
|
||||
- Global dirty / locked / prunable: `0 / 0 / 0`
|
||||
- OID sync: `fully_synced`
|
||||
- Production traffic switched: `false`
|
||||
- Tag: `ABSENT`
|
||||
|
||||
## Remaining mandatory validation
|
||||
|
||||
本次最终元数据闭环提交将形成新的 Release OID。必须针对该最终 OID 再次复验 Gate A、Gate B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正向/负向验证。复验通过前不得创建 Tag 或部署预生产/生产。
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## V2R1C1 - 2.1.0-rc.1
|
||||
|
||||
- 固定发布基线为 `fb739f6e8ff9c031d319221ace68dce157694086`。
|
||||
- 收口 Hermes 生命周期、Grant 隔离、Dispatch 恢复、运行证据和回滚/前滚验证。
|
||||
- Portal Adapter 与 Spark Provisioning 仅保留在 Archive,不属于产品发布图。
|
||||
- Future Incubator 保持隔离,不属于 V2R1C1 发布输入。
|
||||
@@ -6,6 +6,7 @@ from .adapter import (
|
||||
HermesNotFound,
|
||||
HermesRuntimeError,
|
||||
InMemoryHermesStore,
|
||||
SQLiteHermesStore,
|
||||
)
|
||||
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||
from .worker import HermesWorker, WorkerRunResult
|
||||
@@ -21,5 +22,6 @@ __all__ = [
|
||||
"HermesRuntimeError",
|
||||
"HermesWorker",
|
||||
"InMemoryHermesStore",
|
||||
"SQLiteHermesStore",
|
||||
"WorkerRunResult",
|
||||
]
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .contract import (
|
||||
@@ -41,6 +43,118 @@ class InMemoryHermesStore:
|
||||
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
|
||||
|
||||
|
||||
class SQLiteHermesStore(InMemoryHermesStore):
|
||||
"""Durable snapshot for the existing Hermes queue state machine.
|
||||
|
||||
The adapter remains the sole owner of transitions and lease rules. This
|
||||
store only makes its jobs, results, idempotency keys and worker heartbeats
|
||||
survive a process restart.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
super().__init__()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hermes_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._load()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
|
||||
record = asdict(value)
|
||||
record["state"] = value.state.value
|
||||
return record
|
||||
|
||||
def _load(self) -> None:
|
||||
with self.lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload FROM hermes_state WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
payload = json.loads(str(row[0]))
|
||||
self.jobs = {
|
||||
str(item["job_id"]): HermesJob(**item)
|
||||
for item in payload.get("jobs", [])
|
||||
}
|
||||
self.results = {
|
||||
str(item["job_id"]): HermesResult(**item)
|
||||
for item in payload.get("results", [])
|
||||
}
|
||||
self.idempotency = {
|
||||
(str(item["tenant_id"]), str(item["idempotency_key"])): (
|
||||
str(item["job_id"]),
|
||||
str(item["digest"]),
|
||||
)
|
||||
for item in payload.get("idempotency", [])
|
||||
}
|
||||
self.worker_heartbeats = {
|
||||
str(item["worker_id"]): (
|
||||
str(item["heartbeat_at"]),
|
||||
str(item["job_id"]) if item.get("job_id") is not None else None,
|
||||
)
|
||||
for item in payload.get("worker_heartbeats", [])
|
||||
}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"jobs": [self._record(item) for item in self.jobs.values()],
|
||||
"results": [self._record(item) for item in self.results.values()],
|
||||
"idempotency": [
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"job_id": job_id,
|
||||
"digest": digest,
|
||||
}
|
||||
for (tenant_id, idempotency_key), (job_id, digest)
|
||||
in self.idempotency.items()
|
||||
],
|
||||
"worker_heartbeats": [
|
||||
{
|
||||
"worker_id": worker_id,
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"job_id": job_id,
|
||||
}
|
||||
for worker_id, (heartbeat_at, job_id)
|
||||
in self.worker_heartbeats.items()
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
|
||||
""",
|
||||
(encoded,),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
class HermesAdapter:
|
||||
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||
@@ -118,6 +232,7 @@ class HermesAdapter:
|
||||
return self.store.jobs[prior_id]
|
||||
self.store.jobs[job.job_id] = job
|
||||
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||
self.store.persist_locked()
|
||||
return job
|
||||
|
||||
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||
@@ -138,7 +253,8 @@ class HermesAdapter:
|
||||
) -> list[HermesJob]:
|
||||
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
jobs = [
|
||||
job
|
||||
for job in self.store.jobs.values()
|
||||
@@ -157,6 +273,7 @@ class HermesAdapter:
|
||||
profile_id: str,
|
||||
worker_id: str,
|
||||
lease_seconds: int | None = None,
|
||||
job_id: str | None = None,
|
||||
) -> HermesJob | None:
|
||||
worker_id = str(worker_id or "").strip()
|
||||
if not worker_id:
|
||||
@@ -172,10 +289,12 @@ class HermesAdapter:
|
||||
if job.tenant_id == tenant_id
|
||||
and job.cell_id == cell_id
|
||||
and job.profile_id == profile_id
|
||||
and (job_id is None or job.job_id == job_id)
|
||||
and job.state == HermesJobState.QUEUED
|
||||
]
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
if not candidates:
|
||||
self.store.persist_locked()
|
||||
return None
|
||||
current = sorted(
|
||||
candidates,
|
||||
@@ -190,6 +309,7 @@ class HermesAdapter:
|
||||
),
|
||||
)
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||
self.store.persist_locked()
|
||||
return claimed
|
||||
|
||||
def start(
|
||||
@@ -201,9 +321,12 @@ class HermesAdapter:
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesJob:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
self._assert_revision(current, expected_revision)
|
||||
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||
updated = self._transition_locked(current, HermesJobState.RUNNING)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
@@ -214,7 +337,10 @@ class HermesAdapter:
|
||||
lease_seconds: int | None = None,
|
||||
) -> HermesJob:
|
||||
duration = int(lease_seconds or self.default_lease_seconds)
|
||||
if duration < 5:
|
||||
raise ValueError("lease_seconds must be >= 5")
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||
raise HermesConflict("job does not accept heartbeat")
|
||||
@@ -228,6 +354,7 @@ class HermesAdapter:
|
||||
)
|
||||
self.store.jobs[job_id] = updated
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def complete(
|
||||
@@ -283,6 +410,8 @@ class HermesAdapter:
|
||||
result = self.store.results.get(job_id)
|
||||
if result is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if result.state != HermesJobState.CANCELLED:
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
return result
|
||||
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||
result = HermesResult(
|
||||
@@ -296,6 +425,7 @@ class HermesAdapter:
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||
@@ -309,9 +439,18 @@ class HermesAdapter:
|
||||
raise HermesConflict("job has no terminal result")
|
||||
return result
|
||||
|
||||
def requeue_expired(self) -> tuple[str, ...]:
|
||||
"""Persistently release expired leases without changing policy state."""
|
||||
with self.store.lock:
|
||||
changed = self._requeue_expired_locked()
|
||||
if changed:
|
||||
self.store.persist_locked()
|
||||
return tuple(sorted(changed))
|
||||
|
||||
def health(self) -> HermesHealth:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
active_jobs = sum(
|
||||
1 for job in self.store.jobs.values()
|
||||
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||
@@ -354,7 +493,20 @@ class HermesAdapter:
|
||||
expected_revision: int | None,
|
||||
) -> HermesResult:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state in TERMINAL_STATES:
|
||||
existing = self.store.results.get(job_id)
|
||||
if existing is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if (
|
||||
existing.state == state
|
||||
and existing.worker_id == worker_id
|
||||
and existing.output == output
|
||||
and existing.error == error
|
||||
):
|
||||
return existing
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
self._assert_revision(current, expected_revision)
|
||||
updated = self._transition_locked(current, state)
|
||||
result = HermesResult(
|
||||
@@ -371,6 +523,7 @@ class HermesAdapter:
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||
@@ -418,8 +571,9 @@ class HermesAdapter:
|
||||
self.store.jobs[current.job_id] = updated
|
||||
return updated
|
||||
|
||||
def _requeue_expired_locked(self) -> None:
|
||||
def _requeue_expired_locked(self) -> set[str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
changed: set[str] = set()
|
||||
for current in list(self.store.jobs.values()):
|
||||
expiry = self._parse(current.lease_expires_at)
|
||||
if (
|
||||
@@ -435,3 +589,5 @@ class HermesAdapter:
|
||||
lease_expires_at=None,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
changed.add(current.job_id)
|
||||
return changed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AIOS-managed OpenClaw execution adapter."""
|
||||
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||
|
||||
from .adapter import (
|
||||
AdapterError,
|
||||
@@ -9,7 +9,11 @@ from .adapter import (
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from .contract import (
|
||||
CONTRACT_VERSION,
|
||||
SIGNATURE_ALGORITHM,
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
Ed25519CommandVerifier,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
@@ -20,10 +24,13 @@ from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeT
|
||||
|
||||
__all__ = [
|
||||
"AdapterError",
|
||||
"CONTRACT_VERSION",
|
||||
"CommandConflict",
|
||||
"CommandNotFound",
|
||||
"CommandRejected",
|
||||
"CommandState",
|
||||
"Ed25519CommandSigner",
|
||||
"Ed25519CommandVerifier",
|
||||
"HTTPRuntimeTransport",
|
||||
"InMemoryCallbackSink",
|
||||
"InProcessRuntimeTransport",
|
||||
@@ -32,6 +39,7 @@ __all__ = [
|
||||
"OpenClawResult",
|
||||
"RuntimeHealth",
|
||||
"RuntimeTransport",
|
||||
"SIGNATURE_ALGORITHM",
|
||||
"sign_command",
|
||||
"verify_command_signature",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -8,9 +9,10 @@ import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||
SIGNATURE_ALGORITHM = "Ed25519"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
@@ -38,7 +40,106 @@ def _required(value: str, label: str) -> str:
|
||||
|
||||
|
||||
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||
return json.loads(
|
||||
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
|
||||
)
|
||||
|
||||
|
||||
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
||||
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
class CommandSigner(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def sign(self, payload: bytes) -> bytes: ...
|
||||
|
||||
|
||||
class CommandVerifier(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
||||
|
||||
|
||||
class Ed25519CommandSigner:
|
||||
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
if not isinstance(private_key, private_cls):
|
||||
raise ValueError("private key is not Ed25519")
|
||||
self.private_key = private_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(raw, password=None)
|
||||
elif len(raw) == 32:
|
||||
key = private_cls.from_private_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
return self.private_key.sign(bytes(payload))
|
||||
|
||||
def verifier(self) -> "Ed25519CommandVerifier":
|
||||
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||
|
||||
|
||||
class Ed25519CommandVerifier:
|
||||
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
if not isinstance(public_key, public_cls):
|
||||
raise ValueError("public key is not Ed25519")
|
||||
self.public_key = public_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
||||
serialization, private_cls, public_cls = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
try:
|
||||
key = serialization.load_pem_public_key(raw)
|
||||
except ValueError:
|
||||
private = serialization.load_pem_private_key(raw, password=None)
|
||||
if not isinstance(private, private_cls):
|
||||
raise ValueError("key is not Ed25519")
|
||||
key = private.public_key()
|
||||
elif len(raw) == 32:
|
||||
key = public_cls.from_public_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool:
|
||||
try:
|
||||
self.public_key.verify(bytes(signature), bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CommandState(str, Enum):
|
||||
@@ -63,22 +164,43 @@ class OpenClawCommand:
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
expires_at: str | None = None
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
signature_algorithm: str = SIGNATURE_ALGORITHM
|
||||
key_id: str = "default"
|
||||
public_key_fingerprint: str = ""
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in (
|
||||
"command_id", "tenant_id", "task_id", "trace_id", "action",
|
||||
"idempotency_key", "created_at", "contract_version", "key_id",
|
||||
"command_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"action",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
"contract_version",
|
||||
"signature_algorithm",
|
||||
"key_id",
|
||||
):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
if self.contract_version != CONTRACT_VERSION:
|
||||
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
||||
raise ValueError(
|
||||
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
||||
)
|
||||
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||
object.__setattr__(
|
||||
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
||||
)
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
_parse_timestamp(self.created_at)
|
||||
if self.expires_at is not None:
|
||||
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||
_parse_timestamp(self.expires_at)
|
||||
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
||||
raise ValueError("expires_at must be later than created_at")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -118,7 +240,9 @@ class OpenClawCommand:
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"contract_version": self.contract_version,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"key_id": self.key_id,
|
||||
"public_key_fingerprint": self.public_key_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +262,9 @@ class OpenClawResult:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
object.__setattr__(self, "state", CommandState(self.state))
|
||||
if self.output is not None:
|
||||
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||
@@ -165,8 +291,16 @@ class RuntimeHealth:
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
for field_name in (
|
||||
"runtime",
|
||||
"runtime_id",
|
||||
"status",
|
||||
"version",
|
||||
"heartbeat_at",
|
||||
):
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
_parse_timestamp(self.heartbeat_at)
|
||||
if self.active_tasks < 0:
|
||||
raise ValueError("active_tasks must be >= 0")
|
||||
@@ -175,32 +309,68 @@ class RuntimeHealth:
|
||||
|
||||
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
return json.dumps(
|
||||
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
command.unsigned_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
raise ValueError("signing material is required")
|
||||
signature = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
return replace(command, signature=signature)
|
||||
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
||||
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
||||
if isinstance(value, Ed25519CommandSigner):
|
||||
return value.verifier()
|
||||
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
||||
signer = _as_signer(command, signing_key)
|
||||
if signer.key_id != command.key_id:
|
||||
raise ValueError("command key_id does not match signer")
|
||||
prepared = replace(
|
||||
command,
|
||||
signature_algorithm=SIGNATURE_ALGORITHM,
|
||||
public_key_fingerprint=signer.fingerprint,
|
||||
signature="",
|
||||
)
|
||||
signature = base64.b64encode(
|
||||
signer.sign(canonical_command_bytes(prepared))
|
||||
).decode("ascii")
|
||||
return replace(prepared, signature=signature)
|
||||
|
||||
|
||||
def verify_command_signature(
|
||||
command: OpenClawCommand,
|
||||
key_material: bytes | str,
|
||||
verification_key: Any,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||
) -> bool:
|
||||
if not command.signature:
|
||||
if (
|
||||
not command.signature
|
||||
or command.contract_version != CONTRACT_VERSION
|
||||
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||
):
|
||||
return False
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
try:
|
||||
verifier = _as_verifier(command, verification_key)
|
||||
signature = base64.b64decode(command.signature, validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
expected = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, command.signature):
|
||||
if verifier.key_id != command.key_id:
|
||||
return False
|
||||
if not command.public_key_fingerprint or not hmac.compare_digest(
|
||||
command.public_key_fingerprint, verifier.fingerprint
|
||||
):
|
||||
return False
|
||||
if not verifier.verify(canonical_command_bytes(command), signature):
|
||||
return False
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
created = _parse_timestamp(command.created_at)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AIOS V2 control plane and execution orchestration."""
|
||||
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
@@ -20,6 +20,41 @@ from .control_plane import (
|
||||
)
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||
from .provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
PRODUCTION_POLICY_VERSION,
|
||||
ProvenanceAuthorization,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceError,
|
||||
ProvenanceExpired,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceNotFound,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from .provenance_git import (
|
||||
GitCommandResult,
|
||||
GitExecutionContext,
|
||||
ProvenanceGitError,
|
||||
ProvenanceGitWriter,
|
||||
)
|
||||
from .provenance_operations import (
|
||||
OperationContext,
|
||||
OperationReceipt,
|
||||
ProvenanceOperationGate,
|
||||
build_provenance_writer_from_env,
|
||||
context_from_environment,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentRecord",
|
||||
@@ -27,20 +62,49 @@ __all__ = [
|
||||
"ControlPlaneError",
|
||||
"ControlPlaneService",
|
||||
"DispatchRecord",
|
||||
"Ed25519PrivateKeySigner",
|
||||
"Ed25519PublicKeyVerifier",
|
||||
"ExecutionEvidence",
|
||||
"ExecutionOrchestrator",
|
||||
"ExecutionPath",
|
||||
"ExecutionTracker",
|
||||
"GitCommandResult",
|
||||
"GitExecutionContext",
|
||||
"GitOperation",
|
||||
"IdempotencyConflict",
|
||||
"InMemoryControlPlaneStore",
|
||||
"InvalidTransition",
|
||||
"NotFound",
|
||||
"OperationContext",
|
||||
"OperationReceipt",
|
||||
"OrchestrationError",
|
||||
"PRODUCTION_POLICY_VERSION",
|
||||
"PolicyDenied",
|
||||
"ProvenanceAuthorization",
|
||||
"ProvenanceControlledWriter",
|
||||
"ProvenanceDenied",
|
||||
"ProvenanceError",
|
||||
"ProvenanceExpired",
|
||||
"ProvenanceGitError",
|
||||
"ProvenanceGitWriter",
|
||||
"ProvenanceIssuer",
|
||||
"ProvenanceNotFound",
|
||||
"ProvenanceOperationGate",
|
||||
"ProvenanceReplay",
|
||||
"ProvenanceRevoked",
|
||||
"ProvenanceSignatureError",
|
||||
"ProvenanceStatus",
|
||||
"ProvenanceVerifier",
|
||||
"RequestContext",
|
||||
"RevisionConflict",
|
||||
"RunProvenance",
|
||||
"SQLiteProvenanceStore",
|
||||
"TaskRecord",
|
||||
"TaskState",
|
||||
"TenantIsolationError",
|
||||
"TenantRecord",
|
||||
"Unauthorized",
|
||||
"build_provenance_writer_from_env",
|
||||
"context_from_environment",
|
||||
"digest_payload",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Production-candidate AIOS V2 application entrypoint.
|
||||
|
||||
This process owns the public control-plane API and read-only web console. It
|
||||
loads durable control-plane state, dispatches approved work through runtime
|
||||
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||
and never exposes runtime credentials to the browser.
|
||||
"""
|
||||
"""AIOS V2 production-candidate application entrypoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
@@ -18,17 +12,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
|
||||
from aios_openclaw import (
|
||||
Ed25519CommandSigner,
|
||||
HTTPRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||
|
||||
from .console import CONSOLE_HTML
|
||||
from .control_plane import AuditEvent, RequestContext
|
||||
from .execution_tracking import ExecutionTracker
|
||||
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
||||
from .dispatch_store import SQLiteDispatchRepository
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .gateway import GatewayApplication, error_response
|
||||
from .gitops_status import collect_gitops_status
|
||||
from .orchestrator import ExecutionOrchestrator
|
||||
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
from .provenance_operations import build_provenance_writer_from_env
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
@@ -46,27 +46,90 @@ def _json_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
class FileSigningMaterialResolver:
|
||||
"""Resolve runtime signing material from a root-owned file reference."""
|
||||
def _enabled(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||
del tenant_id, key_id
|
||||
metadata = self.path.stat()
|
||||
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
||||
target = name.lower()
|
||||
for key, value in headers.items():
|
||||
if str(key).lower() == target:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
return None
|
||||
|
||||
|
||||
def _provenance_context(
|
||||
headers: Mapping[str, str],
|
||||
payload: Mapping[str, Any],
|
||||
) -> tuple[str | None, str | None]:
|
||||
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
||||
header_run = _header(headers, "X-AIOS-Run-ID")
|
||||
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
||||
body_run = str(payload.get("run_id") or "").strip() or None
|
||||
if header_provenance and body_provenance and header_provenance != body_provenance:
|
||||
raise ValueError("provenance_id header/body mismatch")
|
||||
if header_run and body_run and header_run != body_run:
|
||||
raise ValueError("run_id header/body mismatch")
|
||||
return header_provenance or body_provenance, header_run or body_run
|
||||
|
||||
|
||||
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(str(path), flags)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("signing material reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||
value = self.path.read_bytes().strip()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("signing material is too short")
|
||||
raise RuntimeError("key reference is not a regular file")
|
||||
denied = 0o077 if private else 0o022
|
||||
if metadata.st_mode & denied:
|
||||
raise RuntimeError(
|
||||
"private key file permissions must be 0600 or stricter"
|
||||
if private
|
||||
else "public key file must not be group/world writable"
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = os.read(descriptor, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
value = b"".join(chunks)
|
||||
if not value:
|
||||
raise RuntimeError("key file is empty")
|
||||
return value
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class FileCommandSignerResolver:
|
||||
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
||||
|
||||
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.configured_key_id = str(configured_key_id or "").strip()
|
||||
if not self.configured_key_id:
|
||||
raise ValueError("configured_key_id is required")
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
||||
if not str(tenant_id or "").strip():
|
||||
raise RuntimeError("tenant_id is required for command signing")
|
||||
if key_id != self.configured_key_id:
|
||||
raise RuntimeError("OpenClaw signing key_id mismatch")
|
||||
return Ed25519CommandSigner.from_bytes(
|
||||
_read_protected_file(self.path, private=True),
|
||||
key_id=key_id,
|
||||
)
|
||||
|
||||
|
||||
class ControlPlaneEventPublisher(EventPublisher):
|
||||
"""Append OpenClaw runtime events to the tenant audit stream."""
|
||||
"""Append Runtime callback events to the tenant audit stream.
|
||||
|
||||
Callback audit remains informational. Authoritative task reconciliation is
|
||||
performed only by the orchestrator after dispatch/result validation.
|
||||
"""
|
||||
|
||||
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||
self.service = service
|
||||
@@ -80,16 +143,20 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
raise ValueError("runtime audit event is missing tenant identity")
|
||||
state_value = str(event.get("state") or "unknown").lower()
|
||||
outcome = "allowed" if state_value == "completed" else state_value
|
||||
runtime = str(event.get("runtime") or "openclaw").strip().lower()
|
||||
principal_id = str(
|
||||
event.get("principal_id") or f"{runtime}-runtime"
|
||||
).strip()
|
||||
audit = AuditEvent(
|
||||
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||
tenant_id=tenant_id,
|
||||
principal_id="openclaw-runtime",
|
||||
principal_id=principal_id,
|
||||
trace_id=trace_id,
|
||||
action=event_type,
|
||||
resource_type="openclaw_command",
|
||||
resource_type=f"{runtime}_execution",
|
||||
resource_id=command_id,
|
||||
outcome=outcome,
|
||||
policy_version="runtime-v1",
|
||||
policy_version="runtime-v2",
|
||||
details=dict(event.get("details") or {}),
|
||||
)
|
||||
with self.service.store.lock:
|
||||
@@ -98,8 +165,6 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: GatewayApplication,
|
||||
@@ -107,16 +172,127 @@ class CandidateApplication:
|
||||
tracker: ExecutionTracker,
|
||||
hermes: HermesAdapter,
|
||||
openclaw: OpenClawAdapter | None,
|
||||
*,
|
||||
hermes_lifecycle_api_enabled: bool = False,
|
||||
) -> None:
|
||||
self.gateway = gateway
|
||||
self.orchestrator = orchestrator
|
||||
self.tracker = tracker
|
||||
self.hermes = hermes
|
||||
self.openclaw = openclaw
|
||||
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
|
||||
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.gateway.context_from_headers(headers)
|
||||
|
||||
@staticmethod
|
||||
def _require_runtime_role(context: RequestContext) -> None:
|
||||
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
||||
raise Unauthorized("runtime reconciliation role is required")
|
||||
|
||||
@staticmethod
|
||||
def _require_control_plane_cancel(context: RequestContext) -> None:
|
||||
required = {"runtime", "platform_admin"}
|
||||
if not required.issubset(set(context.roles)):
|
||||
raise Unauthorized(
|
||||
"Hermes cancellation requires trusted AIOS control-plane authority"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = str(payload.get(name) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
return value
|
||||
|
||||
def _record_hermes_lifecycle(
|
||||
self,
|
||||
context: RequestContext,
|
||||
dispatch: Any,
|
||||
job: Any,
|
||||
*,
|
||||
action: str,
|
||||
worker_id: str | None,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
executor = worker_id or "aios-control-plane"
|
||||
evidence = ExecutionEvidence(
|
||||
execution_id=job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
task_id=job.task_id,
|
||||
agent_id=dispatch.agent_id,
|
||||
executor=executor,
|
||||
branch="runtime/hermes",
|
||||
status=job.state.value.lower(),
|
||||
progress_completed=0,
|
||||
progress_total=3,
|
||||
current_step=action,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
current_evidence = self.tracker.claim(
|
||||
evidence,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
if current_evidence.completed_at is None:
|
||||
self.tracker.complete(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
test_status="runtime_terminal",
|
||||
test_summary={"lifecycle_action": action},
|
||||
result_summary={
|
||||
"state": job.state.value,
|
||||
"worker_id": worker_id,
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
},
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
else:
|
||||
self.tracker.heartbeat(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
current_step=action,
|
||||
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
|
||||
progress_total=3,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
self.runtime_events.publish(
|
||||
{
|
||||
"runtime": "hermes",
|
||||
"tenant_id": job.tenant_id,
|
||||
"principal_id": context.principal_id,
|
||||
"trace_id": job.trace_id,
|
||||
"command_id": job.job_id,
|
||||
"event_type": f"HERMES_{action.upper()}",
|
||||
"state": job.state.value,
|
||||
"details": {
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
"task_id": job.task_id,
|
||||
"worker_id": worker_id,
|
||||
"cell_id": job.cell_id,
|
||||
"profile_id": job.profile_id,
|
||||
"revision": job.revision,
|
||||
"provenance_id": provenance_id,
|
||||
"run_id": run_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
@@ -186,25 +362,261 @@ class CandidateApplication:
|
||||
}
|
||||
|
||||
dispatch_match = __import__("re").fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||
path,
|
||||
r"/api/v2/tasks/([^/]+)/dispatch", path
|
||||
)
|
||||
if method == "POST" and dispatch_match:
|
||||
context = self.context(headers)
|
||||
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||
payload = body if isinstance(body, Mapping) else {}
|
||||
timeout = payload.get("timeout_seconds")
|
||||
provenance_id, run_id = _provenance_context(headers, payload)
|
||||
record = self.orchestrator.dispatch(
|
||||
context,
|
||||
task_id=task_id,
|
||||
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
lifecycle_match = __import__("re").fullmatch(
|
||||
r"/api/v2/internal/runtime/hermes/lifecycle/"
|
||||
r"(claim|start|heartbeat|complete|fail|cancel|status)",
|
||||
path,
|
||||
)
|
||||
if method == "POST" and lifecycle_match:
|
||||
if not self.hermes_lifecycle_api_enabled:
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "route not found",
|
||||
}
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
payload = body
|
||||
action = lifecycle_match.group(1)
|
||||
identity = {
|
||||
name: self._required_identity(payload, name)
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"job_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"principal_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"cell_id",
|
||||
"profile_id",
|
||||
)
|
||||
}
|
||||
header_provenance, header_run = _provenance_context(headers, payload)
|
||||
if header_provenance != identity["provenance_id"]:
|
||||
raise ValueError("provenance_id context mismatch")
|
||||
if header_run != identity["run_id"]:
|
||||
raise ValueError("run_id context mismatch")
|
||||
requeued_job_ids = self.hermes.requeue_expired()
|
||||
dispatch, job = self.orchestrator.hermes_lifecycle_context(
|
||||
context,
|
||||
dispatch_id=identity["dispatch_id"],
|
||||
job_id=identity["job_id"],
|
||||
tenant_id=identity["tenant_id"],
|
||||
task_id=identity["task_id"],
|
||||
trace_id=identity["trace_id"],
|
||||
principal_id=identity["principal_id"],
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
if job.cell_id != identity["cell_id"]:
|
||||
raise ValueError("cell_id mismatch")
|
||||
if job.profile_id != identity["profile_id"]:
|
||||
raise ValueError("profile_id mismatch")
|
||||
|
||||
if action == "status":
|
||||
if job.job_id in requeued_job_ids:
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
job,
|
||||
action="lease_expired_requeue",
|
||||
worker_id=None,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
task = self.orchestrator.control_plane.get_task(
|
||||
context, dispatch.task_id
|
||||
)
|
||||
response: dict[str, Any] = {
|
||||
"job": _json_value(job),
|
||||
"dispatch": _json_value(dispatch),
|
||||
"task": _json_value(task),
|
||||
}
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
response["result"] = _json_value(
|
||||
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
|
||||
)
|
||||
return HTTPStatus.OK, response
|
||||
|
||||
expected_revision = payload.get("expected_revision")
|
||||
expected = (
|
||||
int(expected_revision) if expected_revision is not None else None
|
||||
)
|
||||
if action == "cancel":
|
||||
self._require_control_plane_cancel(context)
|
||||
if payload.get("worker_id") is not None:
|
||||
raise Unauthorized("Hermes workers may observe but not initiate cancel")
|
||||
if payload.get("cancellation_source") != "aios-control-plane":
|
||||
raise ValueError(
|
||||
"cancellation_source must be aios-control-plane"
|
||||
)
|
||||
result = self.hermes.cancel(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
worker_id = self._required_identity(payload, "worker_id")
|
||||
if action == "claim":
|
||||
claimed = self.hermes.claim(
|
||||
tenant_id=job.tenant_id,
|
||||
cell_id=job.cell_id,
|
||||
profile_id=job.profile_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
job_id=job.job_id,
|
||||
)
|
||||
if claimed is None:
|
||||
raise ValueError("Hermes job is not claimable")
|
||||
bound = self.orchestrator.bind_hermes_worker(
|
||||
context,
|
||||
claimed,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
worker_id=worker_id,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
bound,
|
||||
claimed,
|
||||
action="claim",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"job": _json_value(claimed),
|
||||
"dispatch": _json_value(bound),
|
||||
}
|
||||
if action == "start":
|
||||
started = self.hermes.start(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
started,
|
||||
action="start",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(started)}
|
||||
if action == "heartbeat":
|
||||
heartbeat = self.hermes.heartbeat(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
heartbeat,
|
||||
action="heartbeat",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
|
||||
if action == "complete":
|
||||
output = payload.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
raise ValueError("output must be an object")
|
||||
result = self.hermes.complete(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
output=output,
|
||||
expected_revision=expected,
|
||||
)
|
||||
elif action == "fail":
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ValueError("error must be an object")
|
||||
result = self.hermes.fail(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
error=error,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
raise ValueError("unsupported Hermes lifecycle action")
|
||||
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
updated_dispatch = self.orchestrator.dispatch_repository.get(
|
||||
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
|
||||
)
|
||||
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
updated_dispatch,
|
||||
terminal_job,
|
||||
action=action,
|
||||
worker_id=result.worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"result": _json_value(result),
|
||||
"task": _json_value(task),
|
||||
"dispatch": _json_value(updated_dispatch),
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
provenance_id, run_id = _provenance_context(headers, body)
|
||||
if not provenance_id or not run_id:
|
||||
raise ValueError("provenance_id and run_id are required")
|
||||
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
||||
if not dispatch_id:
|
||||
raise ValueError("dispatch_id is required")
|
||||
result = HermesResult(
|
||||
job_id=str(body["job_id"]),
|
||||
tenant_id=str(body["tenant_id"]),
|
||||
@@ -221,14 +633,26 @@ class CandidateApplication:
|
||||
started_at=body.get("started_at"),
|
||||
finished_at=body.get("finished_at"),
|
||||
)
|
||||
task = self.orchestrator.reconcile_hermes(context, result)
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
return HTTPStatus.GONE, {
|
||||
"error": "RuntimeReconcileEndpointMoved",
|
||||
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
||||
}
|
||||
|
||||
return self.gateway.handle(method, raw_path, headers, body)
|
||||
|
||||
|
||||
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSV2Candidate/1.0"
|
||||
server_version = "AIOSV2Candidate/2.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
@@ -258,9 +682,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
self.send_response(int(status_code))
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -291,7 +713,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc:
|
||||
except Exception as exc:
|
||||
status_code, payload = error_response(exc)
|
||||
self._send_json(status_code, payload)
|
||||
|
||||
@@ -315,16 +737,31 @@ def build_application() -> CandidateApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
||||
service = DurableControlPlaneService(repository)
|
||||
tracker = ExecutionTracker(state_dir / "executions")
|
||||
hermes = HermesAdapter()
|
||||
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
||||
provenance_writer = build_provenance_writer_from_env(
|
||||
state_dir=state_dir, required=require_provenance
|
||||
)
|
||||
tracker = ExecutionTracker(
|
||||
state_dir / "executions",
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
)
|
||||
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
|
||||
|
||||
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
signing_key_id = os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
||||
).strip()
|
||||
openclaw = None
|
||||
resolver = None
|
||||
if openclaw_url and material_file:
|
||||
resolver = FileSigningMaterialResolver(material_file)
|
||||
if openclaw_url and signing_file:
|
||||
resolver = FileCommandSignerResolver(
|
||||
signing_file,
|
||||
configured_key_id=signing_key_id,
|
||||
)
|
||||
transport = HTTPRuntimeTransport(
|
||||
openclaw_url,
|
||||
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||
@@ -341,8 +778,21 @@ def build_application() -> CandidateApplication:
|
||||
openclaw=openclaw,
|
||||
hermes=hermes,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_key_id=signing_key_id,
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
dispatch_repository=dispatch_repository,
|
||||
)
|
||||
return CandidateApplication(
|
||||
gateway,
|
||||
orchestrator,
|
||||
tracker,
|
||||
hermes,
|
||||
openclaw,
|
||||
hermes_lifecycle_api_enabled=_enabled(
|
||||
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
|
||||
),
|
||||
)
|
||||
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -1,69 +1,83 @@
|
||||
"""Dependency-free AIOS V2 web console assets.
|
||||
"""Dependency-free, role-aware AIOS unified portal.
|
||||
|
||||
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||
The browser receives only server-authorized capabilities and tenant-scoped data.
|
||||
It never selects a tenant, signs Provenance, mutates Git tags, or calls Runtime
|
||||
services directly.
|
||||
"""
|
||||
|
||||
CONSOLE_HTML = r'''<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>AIOS V2 Console</title>
|
||||
<title>AIOS Unified Portal</title>
|
||||
<style>
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;color:#172033;background:#eef2f7;--nav:#0f172a;--card:#fff;--line:#dfe6ef;--muted:#667085;--blue:#2563eb;--green:#047857;--red:#b42318;--amber:#b54708}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea,select{font:inherit}
|
||||
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#e2e8f0;padding:22px 16px;position:sticky;top:0;height:100vh;overflow:auto}.brand{font-weight:800;font-size:20px;letter-spacing:.2px}.version{font-size:12px;color:#94a3b8;margin:5px 0 22px}.identity{padding:12px;background:#182338;border:1px solid #2b3950;border-radius:12px;margin-bottom:18px}.identity strong{display:block;color:#fff}.identity span{font-size:12px;color:#aebbd0;display:block;margin-top:3px;overflow-wrap:anywhere}.nav button{width:100%;display:flex;gap:10px;align-items:center;border:0;background:transparent;color:#cbd5e1;padding:11px 12px;border-radius:9px;text-align:left;cursor:pointer;margin:2px 0}.nav button:hover,.nav button.active{background:#24324b;color:#fff}.nav .dot{width:8px;height:8px;border-radius:50%;background:#60a5fa}.main{min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 26px;position:sticky;top:0;z-index:4}.topbar h1{font-size:19px;margin:0}.top-actions{display:flex;align-items:center;gap:10px}.timestamp{font-size:12px;color:var(--muted)}.content{padding:24px;max-width:1680px;margin:auto}.notice{background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;color:#1e3a8a;font-size:13px;margin-bottom:18px}.error{background:#fef3f2;border:1px solid #fecdca;color:var(--red);border-radius:10px;padding:10px 12px;white-space:pre-wrap;margin-bottom:14px;display:none}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.card{padding:16px}.card small{color:var(--muted);font-weight:600}.card b{display:block;font-size:28px;margin-top:7px}.panel{padding:17px;margin-bottom:16px;overflow:auto}.panel h2{font-size:16px;margin:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(410px,1fr));gap:16px}.wide{grid-column:1/-1}.muted{color:var(--muted)}.good{color:var(--green);font-weight:700}.bad{color:var(--red);font-weight:700}.warn{color:var(--amber);font-weight:700}.pill{display:inline-block;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;font-size:11px;margin:2px;background:#f8fafc}.pill.good{background:#ecfdf3;border-color:#a6f4c5}.pill.bad{background:#fef3f2;border-color:#fecdca}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:0;border-radius:9px;padding:9px 12px;background:var(--blue);color:#fff;cursor:pointer;font-weight:650}.btn.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn.danger{background:#d92d20}.btn:disabled{opacity:.45;cursor:not-allowed}.icon-btn{padding:7px 10px}.view{display:none}.view.active{display:block}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:top}th{color:#667085;font-weight:700;white-space:nowrap}td{overflow-wrap:anywhere}.empty{padding:24px;text-align:center;color:var(--muted)}.timeline{border-left:2px solid #dbe4f0;margin-left:8px;padding-left:20px}.event{position:relative;padding:0 0 17px}.event:before{content:"";position:absolute;left:-27px;top:4px;width:11px;height:11px;border-radius:50%;background:#3b82f6;border:3px solid #fff;box-shadow:0 0 0 1px #93c5fd}.event strong{font-size:13px}.event p{margin:4px 0;color:var(--muted);font-size:12px}.chain{display:flex;align-items:stretch;gap:7px;overflow:auto;padding:5px 0}.chain-item{min-width:135px;border:1px solid var(--line);border-radius:10px;padding:10px;background:#f8fafc}.chain-arrow{display:flex;align-items:center;color:#94a3b8}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}.field label{font-size:12px;color:#475467;font-weight:650;display:block;margin-bottom:5px}.field input,.field textarea,.field select{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:9px;background:#fff}.field textarea{min-height:90px;resize:vertical}.dialog{border:0;border-radius:14px;padding:0;max-width:650px;width:calc(100% - 32px);box-shadow:0 20px 60px rgba(15,23,42,.28)}.dialog::backdrop{background:rgba(15,23,42,.6)}.dialog-head{padding:16px 18px;border-bottom:1px solid var(--line);font-weight:750}.dialog-body{padding:18px}.dialog-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.kv{display:grid;grid-template-columns:150px minmax(0,1fr);gap:6px 12px;font-size:12px}.kv div:nth-child(odd){color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;font-size:11px;background:#0f172a;color:#dbeafe;border-radius:10px;padding:12px;max-height:300px;overflow:auto}
|
||||
@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav{display:flex;overflow:auto}.nav button{min-width:max-content}.topbar{position:static}.grid{grid-template-columns:1fr}.content{padding:16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||
<main>
|
||||
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||
<section class="cards">
|
||||
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">AIOS Unified Portal</div><div class="version">V2R1C1 · Role-aware Control Plane</div>
|
||||
<div class="identity"><strong id="principal">加载身份…</strong><span id="tenant"></span><span id="roles"></span></div>
|
||||
<nav id="navigation" class="nav"></nav>
|
||||
</aside>
|
||||
<section class="main">
|
||||
<header class="topbar"><h1 id="pageTitle">系统总览</h1><div class="top-actions"><span id="updated" class="timestamp">尚未加载</span><button class="btn secondary icon-btn" id="refresh">刷新</button></div></header>
|
||||
<main class="content">
|
||||
<div class="notice">身份、租户和角色由可信反向代理注入。门户只调用 AIOS Control Plane;Provenance 签发、Tag、生产部署与 Runtime 权威状态仍由受控流程执行。</div>
|
||||
<div id="error" class="error"></div>
|
||||
<section id="overview" class="view active"></section>
|
||||
<section id="tasks" class="view"></section>
|
||||
<section id="authorization" class="view"></section>
|
||||
<section id="runtime" class="view"></section>
|
||||
<section id="evidence" class="view"></section>
|
||||
<section id="audit" class="view"></section>
|
||||
<section id="release" class="view"></section>
|
||||
</main>
|
||||
</section>
|
||||
<section class="grid">
|
||||
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<dialog id="taskDialog" class="dialog">
|
||||
<div class="dialog-head">创建任务</div><form id="taskForm"><div class="dialog-body form-grid">
|
||||
<div class="field"><label>Agent ID</label><input name="agent_id" required></div>
|
||||
<div class="field"><label>Action</label><input name="action" required></div>
|
||||
<div class="field"><label>Idempotency Key</label><input name="idempotency_key" required></div>
|
||||
<div class="field wide"><label>Payload JSON</label><textarea name="payload">{}</textarea></div>
|
||||
</div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="taskDialog">取消</button><button class="btn">创建</button></div></form>
|
||||
</dialog>
|
||||
<dialog id="confirmDialog" class="dialog"><div class="dialog-head" id="confirmTitle">确认操作</div><form id="confirmForm"><div class="dialog-body"><p id="confirmText"></p><div class="field"><label>原因</label><textarea id="confirmReason" required></textarea></div></div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="confirmDialog">取消</button><button class="btn danger">确认</button></div></form></dialog>
|
||||
<script>
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||
async function refresh(){
|
||||
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||
try{
|
||||
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||
]);
|
||||
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||
document.getElementById('auditCount').textContent=audit.events.length;
|
||||
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||
}
|
||||
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state={identity:null,current:'overview',cache:{},confirm:null};
|
||||
const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心',runtime:'运行与运维',evidence:'执行证据',audit:'审计中心',release:'GitOps 与发布'};
|
||||
function has(c){return !!state.identity?.capabilities?.includes(c)}
|
||||
async function api(path,options={}){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json',...(options.headers||{})},...options});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${d.message||d.error||path}`);return d}
|
||||
function err(e){const el=$('#error');el.textContent=e?.message||String(e);el.style.display='block'}function clearErr(){$('#error').style.display='none';$('#error').textContent=''}
|
||||
function pill(v){const s=String(v??'unknown'),c=/complete|pass|healthy|active|synced|approved|used/i.test(s)?'good':/fail|deny|reject|revoke|expire|cancel|unavailable/i.test(s)?'bad':'';return `<span class="pill ${c}">${esc(s)}</span>`}
|
||||
function table(cols,rows){if(!rows?.length)return '<div class="empty">暂无数据</div>';return `<table><thead><tr>${cols.map(x=>`<th>${esc(x[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(x=>`<td>${x[1](r)}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
|
||||
function card(label,value,detail=''){return `<div class="card"><small>${esc(label)}</small><b>${esc(value)}</b>${detail?`<span class="muted">${esc(detail)}</span>`:''}</div>`}
|
||||
function json(v){return `<pre>${esc(JSON.stringify(v,null,2))}</pre>`}
|
||||
function show(view){state.current=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav button').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('#pageTitle').textContent=labels[view]||view;load(view)}
|
||||
function navigation(){const nav=$('#navigation');nav.innerHTML=(state.identity.navigation||[]).map(x=>`<button data-view="${esc(x.id)}"><span class="dot"></span>${esc(x.label)}</button>`).join('');nav.querySelectorAll('button').forEach(b=>b.onclick=()=>show(b.dataset.view));}
|
||||
async function identity(){state.identity=await api('/api/v2/me/capabilities');$('#principal').textContent=state.identity.principal_id;$('#tenant').textContent=`租户:${state.identity.tenant_id}`;$('#roles').textContent=`角色:${state.identity.roles.join(', ')||'无'}`;navigation();if(!state.identity.navigation.some(x=>x.id===state.current))state.current=state.identity.navigation[0]?.id||'overview';show(state.current)}
|
||||
async function load(view){clearErr();try{if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();if(view==='runtime')await runtime();if(view==='evidence')await evidence();if(view==='audit')await audit();if(view==='release')await release();$('#updated').textContent=`更新于 ${new Date().toLocaleString()}`}catch(e){err(e)}}
|
||||
async function overview(){const d=await api('/api/v2/portal/overview');state.cache.overview=d;const s=d.summary||{},rt=d.sections?.runtime?.runtimes||[],rel=d.sections?.release||{};$('#overview').innerHTML=`<div class="cards">${card('任务',s.tasks||0)}${card('活动 Dispatch',s.active_dispatches||0,`总数 ${s.dispatches||0}`)}${card('执行证据',s.evidence||0)}${card('审计事件',s.audit_events||0)}${card('当前版本',rel.version||'—',rel.status||'')}${card('授权 Grant',d.sections?.authorization?.active_grants||0,`总数 ${d.sections?.authorization?.grants||0}`)}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime 健康</h2></div>${table([['Runtime',x=>esc(x.name)],['状态',x=>pill(x.status)],['版本/详情',x=>esc(x.details?.version||x.details?.runtime_id||'—')]],rt)}</div><div class="panel"><div class="panel-head"><h2>任务状态</h2></div>${table([['状态',x=>pill(x[0])],['数量',x=>esc(x[1])]],Object.entries(s.task_states||{}))}</div><div class="panel wide"><div class="panel-head"><h2>最近任务</h2></div>${table([['任务',x=>esc(x.task_id)],['Agent',x=>esc(x.agent_id)],['动作',x=>esc(x.action)],['状态',x=>pill(x.state)],['更新时间',x=>esc(x.updated_at)]],d.recent?.tasks||[])}</div></div>`}
|
||||
async function tasks(){const d=await api('/api/v2/portal/tasks');state.cache.tasks=d.items||[];const create=has('task.create')?'<button class="btn" id="newTask">创建任务</button>':'';$('#tasks').innerHTML=`<div class="panel"><div class="panel-head"><h2>任务全链路</h2><div class="toolbar">${create}</div></div>${(d.items||[]).map(taskBlock).join('')||'<div class="empty">暂无任务</div>'}</div>`;$('#newTask')?.addEventListener('click',()=>$('#taskDialog').showModal());document.querySelectorAll('[data-task-action]').forEach(b=>b.onclick=()=>taskAction(b.dataset.taskId,b.dataset.taskAction,b.dataset.revision))}
|
||||
function taskBlock(x){const t=x.task||{},d=x.dispatch,e=x.evidence||[],a=x.audit||[],actions=(x.available_actions||[]).map(v=>`<button class="btn ${v==='cancel'?'danger':'secondary'}" data-task-action="${v}" data-task-id="${esc(t.task_id)}" data-revision="${esc(t.revision)}">${{approve:'审批',cancel:'取消',dispatch:'调度'}[v]||v}</button>`).join('');return `<div class="panel" style="background:#fbfcfe"><div class="panel-head"><h2>${esc(t.task_id)} · ${esc(t.action)}</h2><div class="toolbar">${pill(t.state)}${actions}</div></div><div class="chain"><div class="chain-item"><b>Task</b><div>${esc(t.agent_id)}</div><small>${esc(t.created_by)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Approval</b><div>${esc(t.approved_by||'待审批')}</div><small>revision ${esc(t.revision)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Provenance</b><div>${esc(d?.provenance_id||'未调度')}</div><small>${esc(d?.authorization_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Dispatch</b><div>${esc(d?.dispatch_id||'—')}</div><small>${pill(d?.state||'not_created')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Runtime</b><div>${esc(d?.runtime||'—')}</div><small>${esc(d?.runtime_execution_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Evidence</b><div>${e.length} 条</div><small>Audit ${a.length} 条</small></div></div></div>`}
|
||||
async function taskAction(id,action,revision){clearErr();try{let body={expected_revision:Number(revision)};if(action==='dispatch'){body={};const p=prompt('Provenance ID(生产调度必填)','');const r=prompt('Run ID(生产调度必填)','');if(p)body.provenance_id=p;if(r)body.run_id=r}await api(`/api/v2/portal/tasks/${encodeURIComponent(id)}/${action}`,{method:'POST',body:JSON.stringify(body)});await tasks()}catch(e){err(e)}}
|
||||
async function authorizations(){const d=await api('/api/v2/portal/authorizations');state.cache.authorization=d;const workflows=(d.workflows||[]),grants=(d.grants||[]);$('#authorization').innerHTML=`<div class="cards">${card('Workflow',d.summary?.workflows||0)}${card('Grant',d.summary?.grants||0)}${card('有效 Grant',d.summary?.active_grants||0)}${card('已撤销',d.summary?.revoked_grants||0)}</div><div class="panel"><div class="panel-head"><h2>Workflow Provenance</h2></div>${table([['ID',x=>esc(x.provenance_id)],['Run / Task',x=>`${esc(x.run_id)}<br>${esc(x.task_id)}`],['Principal / Agent',x=>`${esc(x.principal_id)}<br>${esc(x.agent_id)}`],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-workflow="${esc(x.provenance_id)}">撤销</button>`:'—']],workflows)}</div><div class="panel"><div class="panel-head"><h2>Child Grants</h2></div>${table([['Grant',x=>esc(x.grant_id)],['类型 / Scope',x=>`${esc(x.grant_type)}<br>${esc(x.execution_path)}`],['Git 操作',x=>esc((x.allowed_git_operations||[]).join(', ')||'—')],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-grant="${esc(x.grant_id)}">撤销</button>`:'—']],grants)}</div>`;document.querySelectorAll('[data-revoke-workflow]').forEach(b=>b.onclick=()=>confirmOp('撤销 Workflow',`撤销 ${b.dataset.revokeWorkflow} 将使所有子 Grant 失效。`,reason=>api(`/api/v2/portal/provenance/${encodeURIComponent(b.dataset.revokeWorkflow)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)));document.querySelectorAll('[data-revoke-grant]').forEach(b=>b.onclick=()=>confirmOp('撤销 Grant',`撤销 ${b.dataset.revokeGrant}。`,reason=>api(`/api/v2/portal/grants/${encodeURIComponent(b.dataset.revokeGrant)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)))}
|
||||
async function runtime(){const d=await api('/api/v2/portal/runtime');const runtimes=d.runtimes||[],dispatches=d.dispatches||[],jobs=d.hermes_jobs||[];$('#runtime').innerHTML=`<div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime</h2></div>${table([['名称',x=>esc(x.name)],['状态',x=>pill(x.status)],['详情',x=>esc(JSON.stringify(x.details||{}))]],runtimes)}</div><div class="panel"><div class="panel-head"><h2>Hermes Queue / Lease</h2></div>${table([['Job',x=>esc(x.job_id)],['Cell / Profile',x=>`${esc(x.cell_id)} / ${esc(x.profile_id)}`],['Worker',x=>esc(x.assigned_worker_id||'—')],['状态',x=>pill(x.state)],['Lease',x=>esc(x.lease_expires_at||'—')]],jobs)}</div><div class="panel wide"><div class="panel-head"><h2>Dispatch</h2></div>${table([['Dispatch',x=>esc(x.dispatch_id)],['Task',x=>esc(x.task_id)],['Runtime',x=>esc(x.runtime)],['执行 ID',x=>esc(x.runtime_execution_id)],['Principal',x=>esc(x.principal_id)],['状态',x=>pill(x.state)]],dispatches)}</div></div>`}
|
||||
async function evidence(){const d=await api('/api/v2/portal/evidence');$('#evidence').innerHTML=`<div class="panel"><div class="panel-head"><h2>Execution Evidence</h2></div>${table([['Execution',x=>esc(x.execution_id)],['Task / Agent',x=>`${esc(x.task_id)}<br>${esc(x.agent_id)}`],['Executor',x=>esc(x.executor)],['状态',x=>pill(x.status)],['进度',x=>`${esc(x.progress_completed)}/${esc(x.progress_total)}`],['Test',x=>pill(x.test_status)],['Commit',x=>esc(x.last_commit||'—')],['授权',x=>esc(x.last_authorization_id||'—')]],d.executions||[])}</div>`}
|
||||
async function audit(){const d=await api('/api/v2/portal/audit');const events=(d.events||[]).slice().reverse();$('#audit').innerHTML=`<div class="panel"><div class="panel-head"><h2>统一审计时间线</h2></div><div class="timeline">${events.map(x=>`<div class="event"><strong>${esc(x.action)} · ${pill(x.outcome)}</strong><p>${esc(x.created_at)} · ${esc(x.principal_id)} · ${esc(x.resource_type)}/${esc(x.resource_id)}</p><div class="muted">Trace ${esc(x.trace_id)} · Policy ${esc(x.policy_version)}</div></div>`).join('')||'<div class="empty">暂无审计事件</div>'}</div></div>`}
|
||||
async function release(){const d=await api('/api/v2/portal/release');const g=d.gitops||{};$('#release').innerHTML=`<div class="cards">${card('版本',d.version||'—')}${card('Baseline',d.baseline||'—')}${card('发布状态',d.status||'—')}${card('Git 同步',g.sync_status||'unknown')}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>当前 Release</h2></div><div class="kv"><div>Release OID</div><div>${esc(d.release_oid)}</div><div>Branch</div><div>${esc(g.workspace_branch||g.branch||'—')}</div><div>Workspace</div><div>${pill(g.workspace_clean===true?'clean':g.workspace_clean===false?'dirty':'unknown')}</div><div>Local Sync</div><div>${pill(g.local_sync||'unknown')}</div><div>Gitea Sync</div><div>${pill(g.gitea_sync||'unknown')}</div></div></div><div class="panel"><div class="panel-head"><h2>治理操作</h2></div><p class="muted">门户不直接移动 Tag、合并主线或部署生产。以下仅为可发起的 GitOps 请求目录。</p>${(d.operations||[]).map(x=>pill(x)).join('')||'<span class="muted">当前角色只读</span>'}</div><div class="panel wide"><div class="panel-head"><h2>原始 GitOps 状态</h2></div>${json(g)}</div></div>`}
|
||||
function confirmOp(title,text,fn){state.confirm=fn;$('#confirmTitle').textContent=title;$('#confirmText').textContent=text;$('#confirmReason').value='';$('#confirmDialog').showModal()}
|
||||
$('#taskForm').onsubmit=async e=>{e.preventDefault();clearErr();try{const fd=new FormData(e.target),payload=JSON.parse(fd.get('payload')||'{}');await api('/api/v2/portal/tasks',{method:'POST',body:JSON.stringify({agent_id:fd.get('agent_id'),action:fd.get('action'),idempotency_key:fd.get('idempotency_key'),payload})});$('#taskDialog').close();e.target.reset();await tasks()}catch(e){err(e)}};
|
||||
$('#confirmForm').onsubmit=async e=>{e.preventDefault();const fn=state.confirm;$('#confirmDialog').close();state.confirm=null;try{await fn?.($('#confirmReason').value)}catch(e){err(e)}};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).close());$('#refresh').onclick=()=>load(state.current);
|
||||
identity().catch(err);setInterval(()=>load(state.current),30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
</body></html>'''
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""AIOS V2 持久化 Dispatch 记录。
|
||||
|
||||
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
|
||||
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
class DispatchState(str, Enum):
|
||||
PREPARED = "PREPARED"
|
||||
RUNNING = "RUNNING"
|
||||
QUEUED = "QUEUED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
TERMINAL_DISPATCH_STATES = frozenset(
|
||||
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
dispatch_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
trace_id: str
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
authorization_id: str
|
||||
state: DispatchState = DispatchState.PREPARED
|
||||
expected_worker_id: str | None = None
|
||||
result_digest: str | None = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
completed_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"runtime",
|
||||
"runtime_execution_id",
|
||||
"trace_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"authorization_id",
|
||||
):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
object.__setattr__(self, "state", DispatchState(self.state))
|
||||
now = utc_now()
|
||||
object.__setattr__(self, "created_at", self.created_at or now)
|
||||
object.__setattr__(self, "updated_at", self.updated_at or now)
|
||||
if self.expected_worker_id is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"expected_worker_id",
|
||||
_required(self.expected_worker_id, "expected_worker_id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
runtime: str,
|
||||
runtime_execution_id: str,
|
||||
trace_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
authorization_id: str,
|
||||
) -> "DispatchRecord":
|
||||
return cls(
|
||||
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
runtime=runtime,
|
||||
runtime_execution_id=runtime_execution_id,
|
||||
trace_id=trace_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["state"] = self.state.value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
class DispatchConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchNotFound(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchRepository(Protocol):
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord: ...
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None: ...
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord: ...
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
|
||||
|
||||
|
||||
class InMemoryDispatchRepository:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: dict[str, DispatchRecord] = {}
|
||||
self._by_task: dict[tuple[str, str], str] = {}
|
||||
self._by_runtime: dict[tuple[str, str], str] = {}
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock:
|
||||
task_ref = (record.tenant_id, record.task_id)
|
||||
runtime_ref = (record.tenant_id, record.runtime_execution_id)
|
||||
if task_ref in self._by_task or runtime_ref in self._by_runtime:
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing == record:
|
||||
return record
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch")
|
||||
self._records[record.dispatch_id] = record
|
||||
self._by_task[task_ref] = record.dispatch_id
|
||||
self._by_runtime[runtime_ref] = record.dispatch_id
|
||||
return record
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(dispatch_id)
|
||||
if record is None or record.tenant_id != tenant_id:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return record
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_task.get((tenant_id, task_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
with self._lock:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id
|
||||
if expected_worker_id is not None
|
||||
else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
self._records[dispatch_id] = updated
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
[item for item in self._records.values() if item.tenant_id == tenant_id],
|
||||
key=lambda item: (item.created_at, item.dispatch_id),
|
||||
)
|
||||
|
||||
|
||||
class SQLiteDispatchRepository:
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dispatch_records (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
runtime_execution_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(tenant_id, task_id),
|
||||
UNIQUE(tenant_id, runtime_execution_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
|
||||
ON dispatch_records(tenant_id, state, updated_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _encode(record: DispatchRecord) -> str:
|
||||
return json.dumps(
|
||||
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> DispatchRecord:
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("invalid dispatch record")
|
||||
return DispatchRecord.from_dict(value)
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
record.dispatch_id,
|
||||
record.tenant_id,
|
||||
record.task_id,
|
||||
record.runtime_execution_id,
|
||||
self._encode(record),
|
||||
record.state.value,
|
||||
record.updated_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return record
|
||||
except sqlite3.IntegrityError as exc:
|
||||
connection.rollback()
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing is not None and existing == record:
|
||||
return existing
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return self._decode(row)
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
|
||||
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
|
||||
(
|
||||
_required(runtime_execution_id, "runtime_execution_id"),
|
||||
_required(tenant_id, "tenant_id"),
|
||||
),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(
|
||||
self._encode(updated),
|
||||
updated.state.value,
|
||||
updated.updated_at,
|
||||
updated.dispatch_id,
|
||||
updated.tenant_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
|
||||
(_required(tenant_id, "tenant_id"),),
|
||||
).fetchall()
|
||||
return [self._decode(row) for row in rows]
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Portable multi-provider execution plans and aggregate release gates.
|
||||
|
||||
A plan is a DAG of executor-neutral stages. Only root stages are declared at
|
||||
plan creation. A dependent stage is declared atomically after every dependency
|
||||
passes its Truth + notification gate, so its start deadline cannot expire while
|
||||
it is legitimately waiting for upstream work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .external_task_query import list_records
|
||||
from .task_execution_registry import (
|
||||
ExecutionTaskManifest,
|
||||
ExecutionTruthRecord,
|
||||
ExecutionTruthState,
|
||||
required,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
PLAN_SCHEMA_VERSION = "aios.execution-plan.v2"
|
||||
WAITING_DEPENDENCY = "WAITING_DEPENDENCY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionStageManifest:
|
||||
stage_id: str
|
||||
task_id: str
|
||||
executor_type: str
|
||||
role: str
|
||||
steps: Sequence[str]
|
||||
depends_on: Sequence[str] = ()
|
||||
required_evidence: Sequence[str] = ()
|
||||
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
|
||||
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
start_deadline_seconds: int = 300
|
||||
heartbeat_timeout_seconds: int = 180
|
||||
required: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("stage_id", "task_id", "role", "owner_notification_ref"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
steps = tuple(required(item, "step") for item in self.steps)
|
||||
if not steps:
|
||||
raise ValueError("execution stage requires at least one step")
|
||||
object.__setattr__(self, "steps", steps)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"depends_on",
|
||||
tuple(sorted({required(item, "depends_on") for item in self.depends_on})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_evidence",
|
||||
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"notification_events",
|
||||
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
|
||||
)
|
||||
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
|
||||
raise ValueError("stage deadlines must be positive")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["steps"] = list(self.steps)
|
||||
value["depends_on"] = list(self.depends_on)
|
||||
value["required_evidence"] = list(self.required_evidence)
|
||||
value["notification_events"] = list(self.notification_events)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionStageManifest":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPlanManifest:
|
||||
plan_id: str
|
||||
source_branch: str
|
||||
source_oid: str
|
||||
target_version: str
|
||||
stages: Sequence[ExecutionStageManifest]
|
||||
release_gate_policy: str = "all_required_stages"
|
||||
schema_version: str = PLAN_SCHEMA_VERSION
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("plan_id", "source_branch", "source_oid", "target_version"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != PLAN_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported execution plan schema")
|
||||
if self.release_gate_policy != "all_required_stages":
|
||||
raise ValueError("unsupported release gate policy")
|
||||
stages = tuple(self.stages)
|
||||
if not stages:
|
||||
raise ValueError("execution plan requires stages")
|
||||
stage_ids = [item.stage_id for item in stages]
|
||||
task_ids = [item.task_id for item in stages]
|
||||
if len(stage_ids) != len(set(stage_ids)):
|
||||
raise ValueError("execution plan stage_id values must be unique")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("execution plan task_id values must be unique")
|
||||
known = set(stage_ids)
|
||||
for stage in stages:
|
||||
unknown = sorted(set(stage.depends_on) - known)
|
||||
if unknown:
|
||||
raise ValueError(f"stage {stage.stage_id} has unknown dependencies: {unknown}")
|
||||
if stage.stage_id in stage.depends_on:
|
||||
raise ValueError("stage cannot depend on itself")
|
||||
self._assert_acyclic(stages)
|
||||
object.__setattr__(self, "stages", stages)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assert_acyclic(stages: Sequence[ExecutionStageManifest]) -> None:
|
||||
dependencies = {item.stage_id: set(item.depends_on) for item in stages}
|
||||
resolved: set[str] = set()
|
||||
while len(resolved) < len(stages):
|
||||
ready = {
|
||||
stage_id
|
||||
for stage_id, deps in dependencies.items()
|
||||
if stage_id not in resolved and deps.issubset(resolved)
|
||||
}
|
||||
if not ready:
|
||||
raise ValueError("execution plan contains a dependency cycle")
|
||||
resolved.update(ready)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"plan_id": self.plan_id,
|
||||
"source_branch": self.source_branch,
|
||||
"source_oid": self.source_oid,
|
||||
"target_version": self.target_version,
|
||||
"release_gate_policy": self.release_gate_policy,
|
||||
"stages": [item.as_dict() for item in self.stages],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionPlanManifest":
|
||||
raw = dict(value)
|
||||
raw["stages"] = tuple(
|
||||
ExecutionStageManifest.from_dict(item) for item in raw.get("stages") or ()
|
||||
)
|
||||
return cls(**raw)
|
||||
|
||||
def stage(self, stage_id: str) -> ExecutionStageManifest:
|
||||
normalized = required(stage_id, "stage_id")
|
||||
for stage in self.stages:
|
||||
if stage.stage_id == normalized:
|
||||
return stage
|
||||
raise KeyError(f"execution stage not found: {normalized}")
|
||||
|
||||
|
||||
class SQLiteExecutionPlanStore:
|
||||
"""Durable plan declarations; stage state remains in Truth Registry."""
|
||||
|
||||
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 execution_plans (
|
||||
plan_id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
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(plan: ExecutionPlanManifest) -> str:
|
||||
return json.dumps(
|
||||
plan.as_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def save(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
*,
|
||||
allow_source_oid_supersession: bool = False,
|
||||
) -> None:
|
||||
payload = self._encode(plan)
|
||||
now = utc_now()
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(plan.plan_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["manifest_json"] != payload:
|
||||
previous = json.loads(existing["manifest_json"])
|
||||
replacement = plan.as_dict()
|
||||
previous_oid = previous.pop("source_oid", None)
|
||||
replacement_oid = replacement.pop("source_oid", None)
|
||||
if (
|
||||
not allow_source_oid_supersession
|
||||
or previous != replacement
|
||||
or previous_oid == replacement_oid
|
||||
):
|
||||
connection.rollback()
|
||||
raise RuntimeError(
|
||||
"plan_id already exists with another manifest"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_plans SET manifest_json = ?, updated_at = ? WHERE plan_id = ?",
|
||||
(payload, now, plan.plan_id),
|
||||
)
|
||||
connection.commit()
|
||||
return
|
||||
connection.rollback()
|
||||
return
|
||||
connection.execute(
|
||||
"INSERT INTO execution_plans VALUES (?, ?, ?, ?)",
|
||||
(plan.plan_id, payload, now, now),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get(self, plan_id: str) -> ExecutionPlanManifest:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(required(plan_id, "plan_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("execution plan not found")
|
||||
value = json.loads(row["manifest_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("stored execution plan is invalid")
|
||||
return ExecutionPlanManifest.from_dict(value)
|
||||
|
||||
def list(self) -> list[ExecutionPlanManifest]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans ORDER BY created_at, plan_id"
|
||||
).fetchall()
|
||||
return [
|
||||
ExecutionPlanManifest.from_dict(json.loads(row["manifest_json"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class ExecutionPlanCoordinator:
|
||||
"""Declares dependency-ready stages and evaluates aggregate release gates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
task_coordinator: ExternalTaskCoordinator,
|
||||
plan_store: SQLiteExecutionPlanStore,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.task_coordinator = task_coordinator
|
||||
self.plan_store = plan_store
|
||||
self.providers = providers
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
path: str | Path,
|
||||
*,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> "ExecutionPlanCoordinator":
|
||||
return cls(
|
||||
task_coordinator=ExternalTaskCoordinator.from_file(path),
|
||||
plan_store=SQLiteExecutionPlanStore(path),
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _task_manifest(
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTaskManifest:
|
||||
return ExecutionTaskManifest(
|
||||
task_id=stage.task_id,
|
||||
executor=stage.executor_type,
|
||||
source_branch=plan.source_branch,
|
||||
source_oid=plan.source_oid,
|
||||
target_version=plan.target_version,
|
||||
steps=stage.steps,
|
||||
start_deadline_seconds=stage.start_deadline_seconds,
|
||||
heartbeat_timeout_seconds=stage.heartbeat_timeout_seconds,
|
||||
required_evidence=stage.required_evidence,
|
||||
notification_events=stage.notification_events,
|
||||
owner_notification_ref=stage.owner_notification_ref,
|
||||
manifest_version="aios.external-task.v2",
|
||||
)
|
||||
|
||||
def _record(self, task_id: str) -> ExecutionTruthRecord | None:
|
||||
try:
|
||||
return self.task_coordinator.registry.get(task_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _declare_stage(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTruthRecord:
|
||||
self.providers.validate_manifest(self._task_manifest(plan, stage))
|
||||
return self.task_coordinator.declare(self._task_manifest(plan, stage))
|
||||
|
||||
def declare(self, plan: ExecutionPlanManifest) -> Mapping[str, Any]:
|
||||
for stage in plan.stages:
|
||||
self.providers.get(stage.executor_type)
|
||||
allow_source_oid_supersession = False
|
||||
try:
|
||||
previous = self.plan_store.get(plan.plan_id)
|
||||
except KeyError:
|
||||
previous = None
|
||||
if previous is not None and previous.source_oid != plan.source_oid:
|
||||
previous_value = previous.as_dict()
|
||||
replacement_value = plan.as_dict()
|
||||
previous_value.pop("source_oid")
|
||||
replacement_value.pop("source_oid")
|
||||
root_records = [
|
||||
self._record(stage.task_id)
|
||||
for stage in previous.stages
|
||||
if not stage.depends_on
|
||||
]
|
||||
allow_source_oid_supersession = bool(
|
||||
previous_value == replacement_value
|
||||
and root_records
|
||||
and all(
|
||||
record is not None
|
||||
and record.state == ExecutionTruthState.NOT_STARTED
|
||||
for record in root_records
|
||||
)
|
||||
)
|
||||
self.plan_store.save(
|
||||
plan,
|
||||
allow_source_oid_supersession=allow_source_oid_supersession,
|
||||
)
|
||||
for stage in plan.stages:
|
||||
if not stage.depends_on:
|
||||
self._declare_stage(plan, stage)
|
||||
return self.snapshot(plan.plan_id)
|
||||
|
||||
def declare_file(self, path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("execution plan file must contain a JSON object")
|
||||
return self.declare(ExecutionPlanManifest.from_dict(value))
|
||||
|
||||
def dependency_gate(self, plan_id: str, stage_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stage = plan.stage(stage_id)
|
||||
dependencies: list[Mapping[str, Any]] = []
|
||||
passed = True
|
||||
for dependency_id in stage.depends_on:
|
||||
dependency = plan.stage(dependency_id)
|
||||
record = self._record(dependency.task_id)
|
||||
if record is None:
|
||||
gate: Mapping[str, Any] = {
|
||||
"task_id": dependency.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
gate = self.task_coordinator.gate(dependency.task_id)
|
||||
dependencies.append(
|
||||
{
|
||||
"stage_id": dependency.stage_id,
|
||||
"task_id": dependency.task_id,
|
||||
"gate": gate,
|
||||
}
|
||||
)
|
||||
if not gate["passed"]:
|
||||
passed = False
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"dependencies": dependencies,
|
||||
}
|
||||
|
||||
def activate_ready(self, plan_id: str) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
activated: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if self._record(stage.task_id) is not None:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if not dependency["passed"]:
|
||||
continue
|
||||
record = self._declare_stage(plan, stage)
|
||||
activated.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": record.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"state": record.state.value,
|
||||
}
|
||||
)
|
||||
return activated
|
||||
|
||||
def discover_ready(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
executor_type: str,
|
||||
) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
normalized = normalize_executor(executor_type)
|
||||
records = {
|
||||
item.task_id: item
|
||||
for item in list_records(
|
||||
self.task_coordinator.registry.path,
|
||||
executor=normalized,
|
||||
states=(
|
||||
ExecutionTruthState.DECLARED.value,
|
||||
ExecutionTruthState.REGISTERED.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
ready: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if stage.executor_type != normalized or stage.task_id not in records:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if dependency["passed"]:
|
||||
ready.append(
|
||||
{
|
||||
"plan_id": plan.plan_id,
|
||||
"stage": stage.as_dict(),
|
||||
"record": records[stage.task_id].as_dict(),
|
||||
}
|
||||
)
|
||||
return ready
|
||||
|
||||
def snapshot(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
record = self._record(stage.task_id)
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if record is None:
|
||||
truth: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
"executor_run_id": None,
|
||||
"executor_id": None,
|
||||
"current_step": 0,
|
||||
"total_steps": len(stage.steps),
|
||||
"evidence": {},
|
||||
"error": {},
|
||||
"notifications": [],
|
||||
}
|
||||
else:
|
||||
truth = self.task_coordinator.status(stage.task_id)
|
||||
stages.append(
|
||||
{
|
||||
"stage": stage.as_dict(),
|
||||
"dependency_gate": dependency,
|
||||
"truth": truth,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"manifest": plan.as_dict(),
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
def gate(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
required_passed = True
|
||||
for stage in plan.stages:
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
record = self._record(stage.task_id)
|
||||
if record is None:
|
||||
truth_gate: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
truth_gate = self.task_coordinator.gate(stage.task_id)
|
||||
stage_passed = bool(truth_gate["passed"] and dependency["passed"])
|
||||
if stage.required and not stage_passed:
|
||||
required_passed = False
|
||||
stages.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": stage.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"role": stage.role,
|
||||
"required": stage.required,
|
||||
"passed": stage_passed,
|
||||
"truth_gate": truth_gate,
|
||||
"dependency_gate": dependency,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"target_version": plan.target_version,
|
||||
"policy": plan.release_gate_policy,
|
||||
"passed": required_passed,
|
||||
"release_blocking": not required_passed,
|
||||
"stages": stages,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Persistent and portable AIOS executor provider catalog.
|
||||
|
||||
The catalog contains descriptors only: provider names, protocol/adapter versions,
|
||||
trust roles, capabilities and configuration schema identifiers. Credentials,
|
||||
endpoints and runtime secrets are intentionally excluded and must be resolved in
|
||||
the destination environment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .executor_protocol import (
|
||||
EXECUTOR_PROTOCOL_VERSION,
|
||||
DescriptorOnlyProvider,
|
||||
ExecutorProvider,
|
||||
ExecutorProviderDescriptor,
|
||||
ExecutorProviderRegistry,
|
||||
default_provider_registry,
|
||||
)
|
||||
|
||||
CATALOG_SCHEMA_VERSION = "aios.executor.catalog.v1"
|
||||
MIGRATION_SCHEMA_VERSION = "aios.executor.migration.v1"
|
||||
|
||||
|
||||
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 sha256_payload(value: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorCatalogSnapshot:
|
||||
catalog: Mapping[str, Any]
|
||||
catalog_sha256: str
|
||||
path: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"catalog": dict(self.catalog),
|
||||
"catalog_sha256": self.catalog_sha256,
|
||||
"path": self.path,
|
||||
}
|
||||
|
||||
|
||||
class FileExecutorCatalogStore:
|
||||
"""Atomic file store for provider descriptors and migration packages."""
|
||||
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def _validate_catalog(value: Mapping[str, Any]) -> ExecutorProviderRegistry:
|
||||
if value.get("schema_version") != CATALOG_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported executor catalog schema")
|
||||
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor catalog protocol mismatch")
|
||||
providers = value.get("providers")
|
||||
if not isinstance(providers, Sequence) or isinstance(providers, (str, bytes)):
|
||||
raise ValueError("executor catalog providers must be an array")
|
||||
return ExecutorProviderRegistry.import_catalog(value)
|
||||
|
||||
def initialize_defaults(self) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
if self.path.exists():
|
||||
return self.load()
|
||||
return self.save(default_provider_registry())
|
||||
|
||||
def load(self) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("executor catalog must contain a JSON object")
|
||||
registry = self._validate_catalog(value)
|
||||
normalized = registry.export_catalog()
|
||||
return ExecutorCatalogSnapshot(
|
||||
catalog=normalized,
|
||||
catalog_sha256=sha256_payload(normalized),
|
||||
path=str(self.path),
|
||||
)
|
||||
|
||||
def registry(
|
||||
self,
|
||||
*,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> ExecutorProviderRegistry:
|
||||
snapshot = self.load()
|
||||
return ExecutorProviderRegistry.import_catalog(
|
||||
snapshot.catalog,
|
||||
provider_factories=provider_factories,
|
||||
)
|
||||
|
||||
def save(self, registry: ExecutorProviderRegistry) -> ExecutorCatalogSnapshot:
|
||||
catalog = registry.export_catalog()
|
||||
self._validate_catalog(catalog)
|
||||
encoded = json.dumps(
|
||||
catalog,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
) + "\n"
|
||||
with self._lock:
|
||||
temporary = self.path.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(encoded, encoding="utf-8")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, self.path)
|
||||
return ExecutorCatalogSnapshot(
|
||||
catalog=catalog,
|
||||
catalog_sha256=sha256_payload(catalog),
|
||||
path=str(self.path),
|
||||
)
|
||||
|
||||
def upsert_descriptor(
|
||||
self,
|
||||
descriptor: ExecutorProviderDescriptor,
|
||||
) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
if self.path.exists():
|
||||
registry = self.registry()
|
||||
else:
|
||||
registry = default_provider_registry()
|
||||
try:
|
||||
registry.register(DescriptorOnlyProvider(descriptor), replace=True)
|
||||
except KeyError:
|
||||
registry.register(DescriptorOnlyProvider(descriptor))
|
||||
return self.save(registry)
|
||||
|
||||
def export_migration_package(
|
||||
self,
|
||||
*,
|
||||
source_environment: str,
|
||||
required_executor_types: Sequence[str] = (),
|
||||
) -> Mapping[str, Any]:
|
||||
snapshot = self.load()
|
||||
registry = ExecutorProviderRegistry.import_catalog(snapshot.catalog)
|
||||
required = tuple(sorted({str(item).strip().lower() for item in required_executor_types if str(item).strip()}))
|
||||
if required:
|
||||
available = {item.executor_type for item in registry.list_descriptors()}
|
||||
missing = sorted(set(required) - available)
|
||||
if missing:
|
||||
raise ValueError(f"migration package is missing providers: {missing}")
|
||||
package = {
|
||||
"schema_version": MIGRATION_SCHEMA_VERSION,
|
||||
"source_environment": str(source_environment or "").strip(),
|
||||
"catalog": snapshot.catalog,
|
||||
"catalog_sha256": snapshot.catalog_sha256,
|
||||
"required_executor_types": list(required),
|
||||
"secret_values_included": False,
|
||||
}
|
||||
if not package["source_environment"]:
|
||||
raise ValueError("source_environment is required")
|
||||
return {
|
||||
**package,
|
||||
"package_sha256": sha256_payload(package),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_migration_package(
|
||||
cls,
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
destination_path: str | Path,
|
||||
destination_environment: str,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> ExecutorCatalogSnapshot:
|
||||
if value.get("schema_version") != MIGRATION_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported executor migration schema")
|
||||
if value.get("secret_values_included") is not False:
|
||||
raise ValueError("executor migration package must not contain secrets")
|
||||
package_without_hash = {key: item for key, item in value.items() if key != "package_sha256"}
|
||||
if sha256_payload(package_without_hash) != value.get("package_sha256"):
|
||||
raise ValueError("executor migration package hash mismatch")
|
||||
catalog = value.get("catalog")
|
||||
if not isinstance(catalog, Mapping):
|
||||
raise ValueError("executor migration package has no catalog")
|
||||
if sha256_payload(catalog) != value.get("catalog_sha256"):
|
||||
raise ValueError("executor catalog hash mismatch")
|
||||
registry = ExecutorProviderRegistry.import_catalog(
|
||||
catalog,
|
||||
provider_factories=provider_factories,
|
||||
)
|
||||
required = tuple(value.get("required_executor_types") or ())
|
||||
available = {item.executor_type for item in registry.list_descriptors()}
|
||||
missing = sorted(set(required) - available)
|
||||
if missing:
|
||||
raise ValueError(f"destination provider factories are missing: {missing}")
|
||||
if not str(destination_environment or "").strip():
|
||||
raise ValueError("destination_environment is required")
|
||||
return cls(destination_path).save(registry)
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Versioned, extensible executor protocol for AIOS external tasks.
|
||||
|
||||
The core lifecycle is executor-neutral. OpenClaw, Hermes, Codex and the scoped
|
||||
ChatGPT supervisor are built-in
|
||||
providers, not hard-coded protocol branches. Future providers register a
|
||||
versioned descriptor and use the same Truth Registry, evidence, notification
|
||||
and release-gate mechanisms without modifying the lifecycle state machine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
|
||||
|
||||
EXECUTOR_PROTOCOL_VERSION = "aios.executor.v1"
|
||||
_PROVIDER_NAME = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
|
||||
_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){0,2}(?:[-+][A-Za-z0-9._-]+)?$")
|
||||
DISCOVERABLE_STATES: Sequence[str] = ("DECLARED", "REGISTERED")
|
||||
|
||||
|
||||
def normalize_executor(value: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not _PROVIDER_NAME.fullmatch(normalized):
|
||||
raise ValueError("executor type must be a lowercase provider identifier")
|
||||
return normalized
|
||||
|
||||
|
||||
def _major(version: str) -> int:
|
||||
normalized = str(version or "").strip()
|
||||
if not _VERSION.fullmatch(normalized):
|
||||
raise ValueError(f"invalid version: {version}")
|
||||
return int(normalized.split(".", 1)[0])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorProviderDescriptor:
|
||||
"""Portable provider metadata stored without credentials or runtime secrets."""
|
||||
|
||||
executor_type: str
|
||||
adapter_version: str
|
||||
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
|
||||
trust_roles: Sequence[str] = ()
|
||||
capabilities: Sequence[str] = ()
|
||||
configuration_schema: str = "aios.executor.config.v1"
|
||||
portable: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
adapter_version = str(self.adapter_version or "").strip()
|
||||
if not _VERSION.fullmatch(adapter_version):
|
||||
raise ValueError("adapter_version must be version-like")
|
||||
object.__setattr__(self, "adapter_version", adapter_version)
|
||||
protocol_version = str(self.protocol_version or "").strip()
|
||||
if not protocol_version.startswith("aios.executor.v"):
|
||||
raise ValueError("unsupported executor protocol namespace")
|
||||
int(protocol_version.rsplit("v", 1)[1])
|
||||
object.__setattr__(self, "protocol_version", protocol_version)
|
||||
roles = tuple(sorted({str(item).strip() for item in self.trust_roles if str(item).strip()}))
|
||||
if not roles:
|
||||
raise ValueError("provider must declare at least one trust role")
|
||||
capabilities = tuple(
|
||||
sorted({str(item).strip() for item in self.capabilities if str(item).strip()})
|
||||
)
|
||||
object.__setattr__(self, "trust_roles", roles)
|
||||
object.__setattr__(self, "capabilities", capabilities)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"configuration_schema",
|
||||
str(self.configuration_schema or "").strip(),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["trust_roles"] = list(self.trust_roles)
|
||||
value["capabilities"] = list(self.capabilities)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutorProviderDescriptor":
|
||||
return cls(**dict(value))
|
||||
|
||||
def protocol_compatible_with(self, other: "ExecutorProviderDescriptor") -> bool:
|
||||
return (
|
||||
self.executor_type == other.executor_type
|
||||
and self.protocol_version == other.protocol_version
|
||||
and _major(self.adapter_version) == _major(other.adapter_version)
|
||||
)
|
||||
|
||||
|
||||
class ExternalExecutorAdapter(Protocol):
|
||||
"""Runtime-specific implementation behind the common lifecycle."""
|
||||
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def discover(self) -> Sequence[ExecutionTruthRecord]: ...
|
||||
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]: ...
|
||||
def notify_owner(self, payload: Mapping[str, Any]) -> str: ...
|
||||
|
||||
|
||||
class ExecutorProvider(Protocol):
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None: ...
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter: ...
|
||||
|
||||
|
||||
class DescriptorOnlyProvider:
|
||||
"""Default provider used when lifecycle integration is supplied externally."""
|
||||
|
||||
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
|
||||
self.descriptor = descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise ValueError("manifest executor does not match provider")
|
||||
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
|
||||
raise RuntimeError(
|
||||
f"provider {self.descriptor.executor_type} requires an external adapter factory"
|
||||
)
|
||||
|
||||
|
||||
class ExecutorProviderRegistry:
|
||||
"""Thread-safe registry supporting runtime registration and catalog migration."""
|
||||
|
||||
def __init__(self, providers: Sequence[ExecutorProvider] = ()) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._providers: dict[str, ExecutorProvider] = {}
|
||||
for provider in providers:
|
||||
self.register(provider)
|
||||
|
||||
def register(self, provider: ExecutorProvider, *, replace: bool = False) -> None:
|
||||
descriptor = provider.descriptor
|
||||
key = normalize_executor(descriptor.executor_type)
|
||||
with self._lock:
|
||||
current = self._providers.get(key)
|
||||
if current is not None and not replace:
|
||||
raise ValueError(f"executor provider already registered: {key}")
|
||||
if current is not None and not descriptor.protocol_compatible_with(
|
||||
current.descriptor
|
||||
):
|
||||
raise ValueError(
|
||||
f"replacement provider is incompatible with registered provider: {key}"
|
||||
)
|
||||
self._providers[key] = provider
|
||||
|
||||
def unregister(self, executor_type: str) -> ExecutorProvider:
|
||||
key = normalize_executor(executor_type)
|
||||
with self._lock:
|
||||
try:
|
||||
return self._providers.pop(key)
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"executor provider is not registered: {key}") from exc
|
||||
|
||||
def get(self, executor_type: str) -> ExecutorProvider:
|
||||
key = normalize_executor(executor_type)
|
||||
with self._lock:
|
||||
provider = self._providers.get(key)
|
||||
if provider is None:
|
||||
raise KeyError(f"executor provider is not registered: {key}")
|
||||
return provider
|
||||
|
||||
def descriptor(self, executor_type: str) -> ExecutorProviderDescriptor:
|
||||
return self.get(executor_type).descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
self.get(manifest.executor).validate_manifest(manifest)
|
||||
|
||||
def trusted(
|
||||
self,
|
||||
*,
|
||||
executor_type: str,
|
||||
roles: Sequence[str],
|
||||
platform_admin: bool = False,
|
||||
) -> bool:
|
||||
if platform_admin or "platform_admin" in set(roles):
|
||||
return True
|
||||
descriptor = self.descriptor(executor_type)
|
||||
return bool(set(descriptor.trust_roles).intersection(set(roles)))
|
||||
|
||||
def list_descriptors(self) -> tuple[ExecutorProviderDescriptor, ...]:
|
||||
with self._lock:
|
||||
return tuple(
|
||||
self._providers[key].descriptor for key in sorted(self._providers)
|
||||
)
|
||||
|
||||
def export_catalog(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"schema_version": "aios.executor.catalog.v1",
|
||||
"protocol_version": EXECUTOR_PROTOCOL_VERSION,
|
||||
"providers": [item.as_dict() for item in self.list_descriptors()],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_catalog(
|
||||
cls,
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> "ExecutorProviderRegistry":
|
||||
if value.get("schema_version") != "aios.executor.catalog.v1":
|
||||
raise ValueError("unsupported executor catalog schema")
|
||||
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor catalog protocol mismatch")
|
||||
factories = dict(provider_factories or {})
|
||||
providers: list[ExecutorProvider] = []
|
||||
for raw in value.get("providers") or ():
|
||||
descriptor = ExecutorProviderDescriptor.from_dict(raw)
|
||||
provider = factories.get(descriptor.executor_type)
|
||||
if provider is not None:
|
||||
if not provider.descriptor.protocol_compatible_with(descriptor):
|
||||
raise ValueError(
|
||||
f"provider factory does not match imported descriptor: {descriptor.executor_type}"
|
||||
)
|
||||
providers.append(provider)
|
||||
else:
|
||||
providers.append(DescriptorOnlyProvider(descriptor))
|
||||
return cls(providers)
|
||||
|
||||
|
||||
BUILTIN_PROVIDER_DESCRIPTORS: Sequence[ExecutorProviderDescriptor] = (
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="openclaw",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("runtime", "openclaw_executor"),
|
||||
capabilities=(
|
||||
"deploy",
|
||||
"http_runtime",
|
||||
"owner_notification",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
),
|
||||
configuration_schema="aios.executor.openclaw.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="hermes",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("runtime", "hermes_executor"),
|
||||
capabilities=(
|
||||
"queue",
|
||||
"lease",
|
||||
"worker",
|
||||
"runtime_validation",
|
||||
"restart_recovery",
|
||||
),
|
||||
configuration_schema="aios.executor.hermes.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="codex",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("codex", "codex_executor"),
|
||||
capabilities=("code", "test", "docs", "gitops_patch", "security_review"),
|
||||
configuration_schema="aios.executor.codex.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="chatgpt-supervisor",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("chatgpt_supervisor",),
|
||||
capabilities=(
|
||||
"acceptance",
|
||||
"deploy",
|
||||
"evidence",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
"wechat_simulation",
|
||||
),
|
||||
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
|
||||
metadata={"official": True, "production_mutation_allowed": False},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def default_provider_registry() -> ExecutorProviderRegistry:
|
||||
return ExecutorProviderRegistry(
|
||||
[DescriptorOnlyProvider(item) for item in BUILTIN_PROVIDER_DESCRIPTORS]
|
||||
)
|
||||
|
||||
|
||||
def trusted_for_executor(
|
||||
*,
|
||||
executor: str,
|
||||
roles: Sequence[str],
|
||||
platform_admin: bool = False,
|
||||
registry: ExecutorProviderRegistry | None = None,
|
||||
) -> bool:
|
||||
return (registry or default_provider_registry()).trusted(
|
||||
executor_type=executor,
|
||||
roles=roles,
|
||||
platform_admin=platform_admin,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorIdentity:
|
||||
executor_type: str
|
||||
executor_id: str
|
||||
executor_run_id: str
|
||||
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
for name in ("executor_id", "executor_run_id"):
|
||||
value = str(getattr(self, name) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
object.__setattr__(self, name, value)
|
||||
if self.protocol_version != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor identity protocol mismatch")
|
||||
|
||||
|
||||
class ExecutorLifecycleSession:
|
||||
"""Executor-neutral lifecycle facade used by every registered provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExternalTaskCoordinator,
|
||||
identity: ExecutorIdentity,
|
||||
*,
|
||||
registry: ExecutorProviderRegistry | None = None,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.identity = identity
|
||||
self.registry = registry or default_provider_registry()
|
||||
self.registry.get(identity.executor_type)
|
||||
|
||||
def _assert_manifest(self, task_id: str) -> ExecutionTaskManifest:
|
||||
record = self.coordinator.registry.get(task_id)
|
||||
manifest = record.manifest
|
||||
self.registry.validate_manifest(manifest)
|
||||
if normalize_executor(manifest.executor) != self.identity.executor_type:
|
||||
raise PermissionError(
|
||||
f"task executor is {manifest.executor}, not {self.identity.executor_type}"
|
||||
)
|
||||
return manifest
|
||||
|
||||
def register(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.register(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
)
|
||||
|
||||
def retry_not_started(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
)
|
||||
|
||||
def claim(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.claim(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
)
|
||||
|
||||
def start(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.start(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
)
|
||||
|
||||
def heartbeat(self, task_id: str, *, current_step: int) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.succeed(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.fail(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
error=error,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("openclaw", executor_id, executor_run_id),
|
||||
)
|
||||
|
||||
|
||||
class HermesExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("hermes", executor_id, executor_run_id),
|
||||
)
|
||||
|
||||
|
||||
class CodexExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("codex", executor_id, executor_run_id),
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Provider-neutral worker runtime for AIOS external executors.
|
||||
|
||||
Providers implement only step execution and notification delivery. Discovery,
|
||||
dependency gates, lifecycle truth, heartbeat, evidence validation and durable
|
||||
notification acknowledgement are identical for every current and future
|
||||
executor Provider.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
|
||||
from .executor_protocol import (
|
||||
ExecutorIdentity,
|
||||
ExecutorLifecycleSession,
|
||||
ExecutorProviderRegistry,
|
||||
normalize_executor,
|
||||
)
|
||||
from .external_task_control import ExternalTaskCoordinator, NotificationRecord
|
||||
from .task_execution_registry import ExecutionTruthState
|
||||
|
||||
|
||||
class StepRunner(Protocol):
|
||||
def run_step(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
stage: ExecutionStageManifest,
|
||||
step: str,
|
||||
step_number: int,
|
||||
executor_id: str,
|
||||
executor_run_id: str,
|
||||
) -> Mapping[str, str]: ...
|
||||
|
||||
|
||||
class NotificationSender(Protocol):
|
||||
def send(self, notification: NotificationRecord) -> str: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageRunResult:
|
||||
plan_id: str
|
||||
stage_id: str
|
||||
task_id: str
|
||||
executor_type: str
|
||||
executor_id: str
|
||||
executor_run_id: str
|
||||
state: str
|
||||
evidence: Mapping[str, str] = field(default_factory=dict)
|
||||
error: Mapping[str, Any] = field(default_factory=dict)
|
||||
delivered_notifications: Sequence[str] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"plan_id": self.plan_id,
|
||||
"stage_id": self.stage_id,
|
||||
"task_id": self.task_id,
|
||||
"executor_type": self.executor_type,
|
||||
"executor_id": self.executor_id,
|
||||
"executor_run_id": self.executor_run_id,
|
||||
"state": self.state,
|
||||
"evidence": dict(self.evidence),
|
||||
"error": dict(self.error),
|
||||
"delivered_notifications": list(self.delivered_notifications),
|
||||
}
|
||||
|
||||
|
||||
class UnifiedExecutorWorker:
|
||||
"""Dependency-aware worker shared by every registered Provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plan_coordinator: ExecutionPlanCoordinator,
|
||||
task_coordinator: ExternalTaskCoordinator,
|
||||
providers: ExecutorProviderRegistry,
|
||||
executor_type: str,
|
||||
executor_id: str,
|
||||
step_runner: StepRunner,
|
||||
notification_sender: NotificationSender,
|
||||
) -> None:
|
||||
self.plan_coordinator = plan_coordinator
|
||||
self.task_coordinator = task_coordinator
|
||||
self.providers = providers
|
||||
self.executor_type = normalize_executor(executor_type)
|
||||
self.executor_id = str(executor_id or "").strip()
|
||||
if not self.executor_id:
|
||||
raise ValueError("executor_id is required")
|
||||
self.providers.get(self.executor_type)
|
||||
self.step_runner = step_runner
|
||||
self.notification_sender = notification_sender
|
||||
|
||||
def _session(self, executor_run_id: str) -> ExecutorLifecycleSession:
|
||||
return ExecutorLifecycleSession(
|
||||
self.task_coordinator,
|
||||
ExecutorIdentity(
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=executor_run_id,
|
||||
),
|
||||
registry=self.providers,
|
||||
)
|
||||
|
||||
def _notification_candidates(self, task_id: str) -> tuple[NotificationRecord, ...]:
|
||||
"""Return idempotent delivery candidates, including prior failures.
|
||||
|
||||
The outbox has a unique ``(task_id, event_type)`` key. Retrying therefore
|
||||
reuses the same notification ID and cannot create duplicate STARTED or
|
||||
SUCCEEDED events. A later successful acknowledgement replaces FAILED
|
||||
with DELIVERED and records the receipt hash.
|
||||
"""
|
||||
candidates: dict[str, NotificationRecord] = {}
|
||||
for status in ("PENDING", "FAILED"):
|
||||
for item in self.task_coordinator.notifications.list(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
):
|
||||
candidates[item.notification_id] = item
|
||||
return tuple(
|
||||
candidates[key]
|
||||
for key in sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
candidates[item].created_at,
|
||||
candidates[item].notification_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def _deliver_notifications(self, task_id: str) -> tuple[str, ...]:
|
||||
delivered: list[str] = []
|
||||
for item in self._notification_candidates(task_id):
|
||||
try:
|
||||
receipt = self.notification_sender.send(item)
|
||||
acknowledged = self.task_coordinator.notifications.acknowledge(
|
||||
item.notification_id,
|
||||
receipt_sha256=receipt,
|
||||
)
|
||||
delivered.append(acknowledged.event_type)
|
||||
except Exception as exc:
|
||||
self.task_coordinator.notifications.fail(
|
||||
item.notification_id,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return tuple(sorted(set(delivered)))
|
||||
|
||||
def retry_failed_notifications(self, *, task_id: str) -> tuple[str, ...]:
|
||||
"""Retry failed owner notifications without changing task truth."""
|
||||
return self._deliver_notifications(task_id)
|
||||
|
||||
def run_ready(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
max_stages: int = 1,
|
||||
) -> list[StageRunResult]:
|
||||
if max_stages < 1:
|
||||
raise ValueError("max_stages must be positive")
|
||||
ready = self.plan_coordinator.discover_ready(
|
||||
plan_id=plan_id,
|
||||
executor_type=self.executor_type,
|
||||
)[:max_stages]
|
||||
return [
|
||||
self.run_stage(
|
||||
plan_id=plan_id,
|
||||
stage_id=item["stage"]["stage_id"],
|
||||
)
|
||||
for item in ready
|
||||
]
|
||||
|
||||
def run_stage(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
stage_id: str,
|
||||
executor_run_id: str | None = None,
|
||||
) -> StageRunResult:
|
||||
plan = self.plan_coordinator.plan_store.get(plan_id)
|
||||
stage = plan.stage(stage_id)
|
||||
if stage.executor_type != self.executor_type:
|
||||
raise PermissionError(
|
||||
f"stage executor is {stage.executor_type}, not {self.executor_type}"
|
||||
)
|
||||
dependency = self.plan_coordinator.dependency_gate(plan_id, stage_id)
|
||||
if not dependency["passed"]:
|
||||
raise RuntimeError("stage dependencies are not satisfied")
|
||||
|
||||
run_id = executor_run_id or f"{self.executor_type}_run_{uuid.uuid4().hex}"
|
||||
session = self._session(run_id)
|
||||
record = self.task_coordinator.registry.get(stage.task_id)
|
||||
evidence: dict[str, str] = dict(record.evidence)
|
||||
delivered: list[str] = []
|
||||
try:
|
||||
if record.state == ExecutionTruthState.DECLARED:
|
||||
record = session.register(stage.task_id)
|
||||
if record.state == ExecutionTruthState.REGISTERED:
|
||||
record = session.claim(stage.task_id)
|
||||
if record.state == ExecutionTruthState.CLAIMED:
|
||||
record = session.start(stage.task_id)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
if record.state != ExecutionTruthState.RUNNING:
|
||||
raise RuntimeError(
|
||||
f"stage cannot execute from state {record.state.value}"
|
||||
)
|
||||
|
||||
# Re-running the current step is intentional: Provider steps must be
|
||||
# idempotent and a crash between side effect and heartbeat cannot be
|
||||
# safely interpreted as completed work.
|
||||
start_step = max(1, record.current_step)
|
||||
for number, step in enumerate(stage.steps, start=1):
|
||||
if number < start_step:
|
||||
continue
|
||||
produced = self.step_runner.run_step(
|
||||
plan_id=plan_id,
|
||||
stage=stage,
|
||||
step=step,
|
||||
step_number=number,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
)
|
||||
if not isinstance(produced, Mapping):
|
||||
raise TypeError("step runner must return an evidence mapping")
|
||||
evidence.update(
|
||||
{
|
||||
str(key).strip(): str(value).strip().lower()
|
||||
for key, value in produced.items()
|
||||
}
|
||||
)
|
||||
record = session.heartbeat(stage.task_id, current_step=number)
|
||||
|
||||
record = session.succeed(stage.task_id, evidence=evidence)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
return StageRunResult(
|
||||
plan_id=plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
task_id=stage.task_id,
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
state=record.state.value,
|
||||
evidence=dict(record.evidence),
|
||||
delivered_notifications=tuple(sorted(set(delivered))),
|
||||
)
|
||||
except Exception as exc:
|
||||
current = self.task_coordinator.registry.get(stage.task_id)
|
||||
if current.state in {
|
||||
ExecutionTruthState.REGISTERED,
|
||||
ExecutionTruthState.CLAIMED,
|
||||
ExecutionTruthState.RUNNING,
|
||||
}:
|
||||
current = session.fail(
|
||||
stage.task_id,
|
||||
error={
|
||||
"code": "executor_stage_failed",
|
||||
"error_type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
},
|
||||
evidence=evidence or None,
|
||||
)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
return StageRunResult(
|
||||
plan_id=plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
task_id=stage.task_id,
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
state=current.state.value,
|
||||
evidence=dict(current.evidence),
|
||||
error=dict(current.error),
|
||||
delivered_notifications=tuple(sorted(set(delivered))),
|
||||
)
|
||||
|
||||
|
||||
class JsonEvidenceStepRunner:
|
||||
"""Adapter for handlers returning evidence-name to SHA256 mappings."""
|
||||
|
||||
def __init__(self, handler) -> None:
|
||||
self.handler = handler
|
||||
|
||||
def run_step(self, **values: Any) -> Mapping[str, str]:
|
||||
result = self.handler(**values)
|
||||
if not isinstance(result, Mapping):
|
||||
raise TypeError("step handler must return a mapping")
|
||||
return {str(key): str(value) for key, value in result.items()}
|
||||
|
||||
|
||||
class JsonReceiptNotificationSender:
|
||||
"""Adapter for senders returning a SHA256 or receipt JSON object."""
|
||||
|
||||
def __init__(self, sender) -> None:
|
||||
self.sender = sender
|
||||
|
||||
def send(self, notification: NotificationRecord) -> str:
|
||||
result = self.sender(notification.as_dict())
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if not isinstance(result, Mapping):
|
||||
raise TypeError(
|
||||
"notification sender must return SHA256 or a receipt object"
|
||||
)
|
||||
encoded = json.dumps(
|
||||
dict(result),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for durable external-task truth and notification receipts.
|
||||
|
||||
The CLI is suitable for OpenClaw workers, systemd jobs and release gates. It
|
||||
never infers execution from the presence of a manifest: each lifecycle action
|
||||
must be recorded explicitly in the shared SQLite registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .external_task_query import list_records
|
||||
|
||||
|
||||
def _json_mapping(value: str | None, path: str | None) -> Mapping[str, Any]:
|
||||
if value and path:
|
||||
raise ValueError("inline JSON and JSON file are mutually exclusive")
|
||||
if path:
|
||||
parsed = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
else:
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, Mapping):
|
||||
raise ValueError("JSON value must be an object")
|
||||
return dict(parsed)
|
||||
|
||||
|
||||
def _coordinator(args: argparse.Namespace) -> ExternalTaskCoordinator:
|
||||
return ExternalTaskCoordinator.from_file(args.store_file)
|
||||
|
||||
|
||||
def _print(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
|
||||
|
||||
|
||||
def cmd_declare(args: argparse.Namespace) -> int:
|
||||
record = _coordinator(args).declare_file(args.manifest)
|
||||
_print(record.as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_discover(args: argparse.Namespace) -> int:
|
||||
records = list_records(
|
||||
args.store_file,
|
||||
executor=args.executor,
|
||||
states=tuple(args.state or ()),
|
||||
)
|
||||
_print({"count": len(records), "items": [item.as_dict() for item in records]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
_print(_coordinator(args).register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_claim(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).claim(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).start(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).heartbeat(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
current_step=args.current_step,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_succeed(args: argparse.Namespace) -> int:
|
||||
evidence = _json_mapping(args.evidence_json, args.evidence_file)
|
||||
_print(
|
||||
_coordinator(args).succeed(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
evidence={str(key): str(value) for key, value in evidence.items()},
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fail(args: argparse.Namespace) -> int:
|
||||
error = _json_mapping(args.error_json, args.error_file)
|
||||
evidence = None
|
||||
if args.evidence_json or args.evidence_file:
|
||||
evidence_value = _json_mapping(args.evidence_json, args.evidence_file)
|
||||
evidence = {str(key): str(value) for key, value in evidence_value.items()}
|
||||
_print(
|
||||
_coordinator(args).fail(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
error=error,
|
||||
evidence=evidence,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
_print(_coordinator(args).status(args.task_id))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_gate(args: argparse.Namespace) -> int:
|
||||
value = _coordinator(args).gate(args.task_id)
|
||||
_print(value)
|
||||
return 0 if value["passed"] else 3
|
||||
|
||||
|
||||
def cmd_notifications(args: argparse.Namespace) -> int:
|
||||
items = _coordinator(args).notifications.list(task_id=args.task_id, status=args.status)
|
||||
_print({"count": len(items), "items": [item.as_dict() for item in items]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ack(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).notifications.acknowledge(
|
||||
args.notification_id,
|
||||
receipt_sha256=args.receipt_sha256,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--store-file",
|
||||
default=os.getenv(
|
||||
"AIOS_EXTERNAL_TASK_STORE_FILE",
|
||||
"/var/lib/aios-v2/external-task-truth.sqlite3",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def lifecycle(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--executor-run-id", required=True)
|
||||
parser.add_argument("--executor-id", required=True)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AIOS external task truth CLI")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
declare = commands.add_parser("declare")
|
||||
common(declare)
|
||||
declare.add_argument("--manifest", required=True)
|
||||
declare.set_defaults(handler=cmd_declare)
|
||||
|
||||
discover = commands.add_parser("discover")
|
||||
common(discover)
|
||||
discover.add_argument("--executor", default="openclaw")
|
||||
discover.add_argument("--state", action="append")
|
||||
discover.set_defaults(handler=cmd_discover)
|
||||
|
||||
register = commands.add_parser("register")
|
||||
common(register)
|
||||
register.add_argument("--task-id", required=True)
|
||||
register.add_argument("--executor-run-id", required=True)
|
||||
register.set_defaults(handler=cmd_register)
|
||||
|
||||
claim = commands.add_parser("claim")
|
||||
common(claim)
|
||||
lifecycle(claim)
|
||||
claim.set_defaults(handler=cmd_claim)
|
||||
|
||||
start = commands.add_parser("start")
|
||||
common(start)
|
||||
lifecycle(start)
|
||||
start.set_defaults(handler=cmd_start)
|
||||
|
||||
heartbeat = commands.add_parser("heartbeat")
|
||||
common(heartbeat)
|
||||
lifecycle(heartbeat)
|
||||
heartbeat.add_argument("--current-step", type=int, required=True)
|
||||
heartbeat.set_defaults(handler=cmd_heartbeat)
|
||||
|
||||
succeed = commands.add_parser("succeed")
|
||||
common(succeed)
|
||||
lifecycle(succeed)
|
||||
evidence = succeed.add_mutually_exclusive_group(required=True)
|
||||
evidence.add_argument("--evidence-json")
|
||||
evidence.add_argument("--evidence-file")
|
||||
succeed.set_defaults(handler=cmd_succeed)
|
||||
|
||||
fail = commands.add_parser("fail")
|
||||
common(fail)
|
||||
lifecycle(fail)
|
||||
error = fail.add_mutually_exclusive_group(required=True)
|
||||
error.add_argument("--error-json")
|
||||
error.add_argument("--error-file")
|
||||
fail_evidence = fail.add_mutually_exclusive_group()
|
||||
fail_evidence.add_argument("--evidence-json")
|
||||
fail_evidence.add_argument("--evidence-file")
|
||||
fail.set_defaults(handler=cmd_fail)
|
||||
|
||||
status = commands.add_parser("status")
|
||||
common(status)
|
||||
status.add_argument("--task-id", required=True)
|
||||
status.set_defaults(handler=cmd_status)
|
||||
|
||||
gate = commands.add_parser("gate")
|
||||
common(gate)
|
||||
gate.add_argument("--task-id", required=True)
|
||||
gate.set_defaults(handler=cmd_gate)
|
||||
|
||||
notifications = commands.add_parser("notifications")
|
||||
common(notifications)
|
||||
notifications.add_argument("--task-id")
|
||||
notifications.add_argument("--status")
|
||||
notifications.set_defaults(handler=cmd_notifications)
|
||||
|
||||
ack = commands.add_parser("ack-notification")
|
||||
common(ack)
|
||||
ack.add_argument("--notification-id", required=True)
|
||||
ack.add_argument("--receipt-sha256", required=True)
|
||||
ack.set_defaults(handler=cmd_ack)
|
||||
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": "error", "error": type(exc).__name__, "message": str(exc)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,420 @@
|
||||
"""External executor coordination, notification outbox and release truth gate.
|
||||
|
||||
OpenClaw/Codex task declarations are not execution evidence. This module wraps
|
||||
``SQLiteExecutionTruthRegistry`` with a durable notification outbox. A successful
|
||||
release gate requires both terminal task evidence and acknowledged STARTED /
|
||||
SUCCEEDED owner notifications when those events are required by the manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .task_execution_registry import (
|
||||
ExecutionTaskManifest,
|
||||
ExecutionTruthRecord,
|
||||
ExecutionTruthState,
|
||||
SQLiteExecutionTruthRegistry,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def sha256_value(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not _SHA256.fullmatch(normalized):
|
||||
raise ValueError(f"{label} must be a lowercase SHA256 hex digest")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationRecord:
|
||||
notification_id: str
|
||||
task_id: str
|
||||
event_type: str
|
||||
target_ref: str
|
||||
payload: Mapping[str, Any]
|
||||
status: str
|
||||
created_at: str
|
||||
delivered_at: str | None = None
|
||||
receipt_sha256: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SQLiteNotificationOutbox:
|
||||
"""Durable owner-notification delivery and acknowledgement records."""
|
||||
|
||||
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 external_task_notifications (
|
||||
notification_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
receipt_sha256 TEXT,
|
||||
error TEXT,
|
||||
UNIQUE(task_id, event_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_external_notification_status
|
||||
ON external_task_notifications(status, created_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 _decode(row: sqlite3.Row) -> NotificationRecord:
|
||||
return NotificationRecord(
|
||||
notification_id=row["notification_id"],
|
||||
task_id=row["task_id"],
|
||||
event_type=row["event_type"],
|
||||
target_ref=row["target_ref"],
|
||||
payload=json.loads(row["payload_json"]),
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
delivered_at=row["delivered_at"],
|
||||
receipt_sha256=row["receipt_sha256"],
|
||||
error=row["error"],
|
||||
)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
event_type: str,
|
||||
target_ref: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> NotificationRecord:
|
||||
now = utc_now()
|
||||
record = NotificationRecord(
|
||||
notification_id=f"notify_{uuid.uuid4().hex}",
|
||||
task_id=str(task_id).strip(),
|
||||
event_type=str(event_type).strip().upper(),
|
||||
target_ref=str(target_ref).strip(),
|
||||
payload=json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str)),
|
||||
status="PENDING",
|
||||
created_at=now,
|
||||
)
|
||||
if not record.task_id or not record.event_type or not record.target_ref:
|
||||
raise ValueError("task_id, event_type and target_ref are required")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE task_id = ? AND event_type = ?",
|
||||
(record.task_id, record.event_type),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
return self._decode(existing)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO external_task_notifications
|
||||
(notification_id, task_id, event_type, target_ref, payload_json,
|
||||
status, created_at, delivered_at, receipt_sha256, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
||||
""",
|
||||
(
|
||||
record.notification_id,
|
||||
record.task_id,
|
||||
record.event_type,
|
||||
record.target_ref,
|
||||
json.dumps(record.payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
|
||||
record.status,
|
||||
record.created_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return record
|
||||
|
||||
def get(self, notification_id: str) -> NotificationRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
|
||||
(str(notification_id).strip(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("notification not found")
|
||||
return self._decode(row)
|
||||
|
||||
def acknowledge(self, notification_id: str, *, receipt_sha256: str) -> NotificationRecord:
|
||||
digest = sha256_value(receipt_sha256, "receipt_sha256")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
|
||||
(str(notification_id).strip(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("notification not found")
|
||||
current = self._decode(row)
|
||||
if current.status == "DELIVERED":
|
||||
if current.receipt_sha256 != digest:
|
||||
connection.rollback()
|
||||
raise RuntimeError("notification already acknowledged with another receipt")
|
||||
connection.rollback()
|
||||
return current
|
||||
delivered_at = utc_now()
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE external_task_notifications
|
||||
SET status = 'DELIVERED', delivered_at = ?, receipt_sha256 = ?, error = NULL
|
||||
WHERE notification_id = ?
|
||||
""",
|
||||
(delivered_at, digest, current.notification_id),
|
||||
)
|
||||
connection.commit()
|
||||
return self.get(current.notification_id)
|
||||
|
||||
def fail(self, notification_id: str, *, error: str) -> NotificationRecord:
|
||||
message = str(error or "").strip()
|
||||
if not message:
|
||||
raise ValueError("notification error is required")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE external_task_notifications SET status = 'FAILED', error = ? WHERE notification_id = ?",
|
||||
(message, str(notification_id).strip()),
|
||||
)
|
||||
connection.commit()
|
||||
return self.get(notification_id)
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[NotificationRecord]:
|
||||
conditions: list[str] = []
|
||||
values: list[str] = []
|
||||
if task_id:
|
||||
conditions.append("task_id = ?")
|
||||
values.append(str(task_id).strip())
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
values.append(str(status).strip().upper())
|
||||
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM external_task_notifications{where} ORDER BY created_at, notification_id",
|
||||
tuple(values),
|
||||
).fetchall()
|
||||
return [self._decode(row) for row in rows]
|
||||
|
||||
|
||||
class ExternalTaskCoordinator:
|
||||
"""Single safe lifecycle used by external executors and release gates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: SQLiteExecutionTruthRegistry,
|
||||
notifications: SQLiteNotificationOutbox,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.notifications = notifications
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> "ExternalTaskCoordinator":
|
||||
return cls(
|
||||
SQLiteExecutionTruthRegistry(path),
|
||||
SQLiteNotificationOutbox(path),
|
||||
)
|
||||
|
||||
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
|
||||
return self.registry.declare(manifest)
|
||||
|
||||
def declare_file(self, path: str | Path) -> ExecutionTruthRecord:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("external task manifest must be a JSON object")
|
||||
return self.declare(ExecutionTaskManifest.from_dict(value))
|
||||
|
||||
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
|
||||
return self.registry.register(task_id, executor_run_id=executor_run_id)
|
||||
|
||||
def retry_not_started(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.claim(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
|
||||
def _notify(self, record: ExecutionTruthRecord, event_type: str) -> NotificationRecord | None:
|
||||
event = event_type.upper()
|
||||
if event not in set(record.manifest.notification_events):
|
||||
return None
|
||||
return self.notifications.enqueue(
|
||||
task_id=record.task_id,
|
||||
event_type=event,
|
||||
target_ref=record.manifest.owner_notification_ref,
|
||||
payload={
|
||||
"task_id": record.task_id,
|
||||
"target_version": record.manifest.target_version,
|
||||
"executor": record.manifest.executor,
|
||||
"executor_id": record.executor_id,
|
||||
"executor_run_id": record.executor_run_id,
|
||||
"state": record.state.value,
|
||||
"current_step": record.current_step,
|
||||
"total_steps": len(record.manifest.steps),
|
||||
"timestamp": utc_now(),
|
||||
},
|
||||
)
|
||||
|
||||
def start(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
record = self.registry.start(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
self._notify(record, "STARTED")
|
||||
return record
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
current_step: int,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
normalized = {
|
||||
str(key).strip(): sha256_value(value, f"evidence[{key}]")
|
||||
for key, value in evidence.items()
|
||||
}
|
||||
record = self.registry.succeed(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=normalized,
|
||||
)
|
||||
self._notify(record, "SUCCEEDED")
|
||||
return record
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
normalized = None
|
||||
if evidence is not None:
|
||||
normalized = {
|
||||
str(key).strip(): sha256_value(value, f"evidence[{key}]")
|
||||
for key, value in evidence.items()
|
||||
}
|
||||
record = self.registry.fail(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
error=error,
|
||||
evidence=normalized,
|
||||
)
|
||||
self._notify(record, "FAILED")
|
||||
return record
|
||||
|
||||
def gate(self, task_id: str) -> Mapping[str, Any]:
|
||||
before = self.registry.get(task_id)
|
||||
truth = self.registry.gate(task_id)
|
||||
after = self.registry.get(task_id)
|
||||
if after.state != before.state and after.state in {
|
||||
ExecutionTruthState.NOT_STARTED,
|
||||
ExecutionTruthState.STALLED,
|
||||
}:
|
||||
self._notify(after, after.state.value)
|
||||
notifications = self.notifications.list(task_id=task_id)
|
||||
delivered = {item.event_type for item in notifications if item.status == "DELIVERED"}
|
||||
required_success_notifications = {
|
||||
item
|
||||
for item in ("STARTED", "SUCCEEDED")
|
||||
if item in set(after.manifest.notification_events)
|
||||
}
|
||||
notification_complete = required_success_notifications.issubset(delivered)
|
||||
passed = bool(truth["passed"] and notification_complete)
|
||||
return {
|
||||
**dict(truth),
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"notification_complete": notification_complete,
|
||||
"required_success_notifications": sorted(required_success_notifications),
|
||||
"delivered_notifications": sorted(delivered),
|
||||
"notifications": [item.as_dict() for item in notifications],
|
||||
}
|
||||
|
||||
def status(self, task_id: str) -> Mapping[str, Any]:
|
||||
gate = self.gate(task_id)
|
||||
record = self.registry.get(task_id)
|
||||
return {
|
||||
"record": record.as_dict(),
|
||||
"gate": gate,
|
||||
"events": self.registry.events(task_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def content_receipt(value: Mapping[str, Any]) -> str:
|
||||
encoded = json.dumps(dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Read models for the durable external-task truth registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from .task_execution_registry import ExecutionTruthRecord, ExecutionTruthState
|
||||
|
||||
|
||||
def _decode(value: str) -> ExecutionTruthRecord:
|
||||
payload = json.loads(value)
|
||||
payload["state"] = ExecutionTruthState(payload["state"])
|
||||
payload["evidence"] = dict(payload.get("evidence") or {})
|
||||
payload["error"] = dict(payload.get("error") or {})
|
||||
return ExecutionTruthRecord(**payload)
|
||||
|
||||
|
||||
def list_records(
|
||||
path: str | Path,
|
||||
*,
|
||||
executor: str | None = None,
|
||||
states: Sequence[str] = (),
|
||||
) -> list[ExecutionTruthRecord]:
|
||||
database = Path(path).resolve()
|
||||
if not database.exists():
|
||||
return []
|
||||
conditions: list[str] = []
|
||||
values: list[str] = []
|
||||
normalized_states = tuple(sorted({ExecutionTruthState(item).value for item in states}))
|
||||
if normalized_states:
|
||||
conditions.append(f"state IN ({','.join('?' for _ in normalized_states)})")
|
||||
values.extend(normalized_states)
|
||||
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=10.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute(
|
||||
f"SELECT record_json FROM execution_truth{where} ORDER BY updated_at, task_id",
|
||||
tuple(values),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
finally:
|
||||
connection.close()
|
||||
records = [_decode(row["record_json"]) for row in rows]
|
||||
if executor:
|
||||
normalized = str(executor).strip().lower()
|
||||
records = [item for item in records if item.manifest.executor.lower() == normalized]
|
||||
return records
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Official AIOS executor providers built on the portable provider protocol.
|
||||
|
||||
The official providers contain no lifecycle implementation. They validate their
|
||||
manifests, resolve a generic ``UnifiedExecutorWorker`` from runtime-supplied
|
||||
boundaries and expose the same adapter contract. Future providers can use
|
||||
``ConfiguredExecutorProvider`` directly without changing AIOS core modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
|
||||
from .executor_protocol import (
|
||||
EXECUTOR_PROTOCOL_VERSION,
|
||||
ExternalExecutorAdapter,
|
||||
ExecutorProviderDescriptor,
|
||||
normalize_executor,
|
||||
)
|
||||
from .executor_worker import NotificationSender, StepRunner, UnifiedExecutorWorker
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRuntimeConfiguration:
|
||||
plan_id: str
|
||||
executor_id: str
|
||||
plan_coordinator: ExecutionPlanCoordinator
|
||||
task_coordinator: ExternalTaskCoordinator
|
||||
providers: Any
|
||||
step_runner: StepRunner
|
||||
notification_sender: NotificationSender
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProviderRuntimeConfiguration":
|
||||
required = (
|
||||
"plan_id",
|
||||
"executor_id",
|
||||
"plan_coordinator",
|
||||
"task_coordinator",
|
||||
"providers",
|
||||
"step_runner",
|
||||
"notification_sender",
|
||||
)
|
||||
missing = [name for name in required if value.get(name) is None]
|
||||
if missing:
|
||||
raise ValueError(f"provider runtime configuration is missing: {missing}")
|
||||
return cls(**{name: value[name] for name in required})
|
||||
|
||||
|
||||
class ConfiguredExecutorAdapter:
|
||||
"""Generic adapter produced by every official or third-party provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
descriptor: ExecutorProviderDescriptor,
|
||||
configuration: ProviderRuntimeConfiguration,
|
||||
) -> None:
|
||||
self.descriptor = descriptor
|
||||
self.configuration = configuration
|
||||
self.worker = UnifiedExecutorWorker(
|
||||
plan_coordinator=configuration.plan_coordinator,
|
||||
task_coordinator=configuration.task_coordinator,
|
||||
providers=configuration.providers,
|
||||
executor_type=descriptor.executor_type,
|
||||
executor_id=configuration.executor_id,
|
||||
step_runner=configuration.step_runner,
|
||||
notification_sender=configuration.notification_sender,
|
||||
)
|
||||
|
||||
def discover(self) -> Sequence[ExecutionTruthRecord]:
|
||||
ready = self.configuration.plan_coordinator.discover_ready(
|
||||
plan_id=self.configuration.plan_id,
|
||||
executor_type=self.descriptor.executor_type,
|
||||
)
|
||||
return tuple(item["record"] for item in ready)
|
||||
|
||||
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise PermissionError("manifest executor does not match adapter provider")
|
||||
plan = self.configuration.plan_coordinator.plan_store.get(
|
||||
self.configuration.plan_id
|
||||
)
|
||||
stage: ExecutionStageManifest | None = None
|
||||
for item in plan.stages:
|
||||
if item.task_id == manifest.task_id:
|
||||
stage = item
|
||||
break
|
||||
if stage is None:
|
||||
raise KeyError("manifest task is not part of configured execution plan")
|
||||
return self.worker.run_stage(
|
||||
plan_id=plan.plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
).as_dict()
|
||||
|
||||
def notify_owner(self, payload: Mapping[str, Any]) -> str:
|
||||
# External callers normally use the durable NotificationOutbox. This
|
||||
# method remains part of the portable adapter contract for providers
|
||||
# that need to send a standalone operational notice.
|
||||
from .external_task_control import NotificationRecord
|
||||
from .task_execution_registry import utc_now
|
||||
|
||||
notification = NotificationRecord(
|
||||
notification_id="provider_direct_notice",
|
||||
task_id=str(payload.get("task_id") or "provider-notice"),
|
||||
event_type=str(payload.get("event_type") or "INFO"),
|
||||
target_ref=str(
|
||||
payload.get("target_ref") or "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
),
|
||||
payload=dict(payload),
|
||||
status="PENDING",
|
||||
created_at=utc_now(),
|
||||
)
|
||||
return self.configuration.notification_sender.send(notification)
|
||||
|
||||
|
||||
class ConfiguredExecutorProvider:
|
||||
"""Reusable provider implementation for current and future executors."""
|
||||
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
|
||||
self.descriptor = descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise ValueError("manifest executor does not match provider")
|
||||
if manifest.manifest_version not in {
|
||||
"aios.external-task.v1",
|
||||
"aios.external-task.v2",
|
||||
}:
|
||||
raise ValueError("unsupported external task manifest version")
|
||||
if not manifest.steps:
|
||||
raise ValueError("executor manifest has no steps")
|
||||
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
|
||||
return ConfiguredExecutorAdapter(
|
||||
self.descriptor,
|
||||
ProviderRuntimeConfiguration.from_mapping(configuration),
|
||||
)
|
||||
|
||||
|
||||
class OpenClawProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="openclaw",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("runtime", "openclaw_executor"),
|
||||
capabilities=(
|
||||
"deploy",
|
||||
"http_runtime",
|
||||
"owner_notification",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
),
|
||||
configuration_schema="aios.executor.openclaw.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class HermesProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="hermes",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("runtime", "hermes_executor"),
|
||||
capabilities=(
|
||||
"queue",
|
||||
"lease",
|
||||
"worker",
|
||||
"runtime_validation",
|
||||
"restart_recovery",
|
||||
),
|
||||
configuration_schema="aios.executor.hermes.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CodexProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="codex",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("codex", "codex_executor"),
|
||||
capabilities=(
|
||||
"code",
|
||||
"test",
|
||||
"docs",
|
||||
"gitops_patch",
|
||||
"security_review",
|
||||
),
|
||||
configuration_schema="aios.executor.codex.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ChatGPTSupervisorProvider(ConfiguredExecutorProvider):
|
||||
"""Explicit takeover provider; it does not inherit Codex permissions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="chatgpt-supervisor",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("chatgpt_supervisor",),
|
||||
capabilities=(
|
||||
"acceptance",
|
||||
"deploy",
|
||||
"evidence",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
"wechat_simulation",
|
||||
),
|
||||
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
|
||||
metadata={"official": True, "production_mutation_allowed": False},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def official_provider_factories() -> Mapping[str, ConfiguredExecutorProvider]:
|
||||
providers = (
|
||||
OpenClawProvider(),
|
||||
HermesProvider(),
|
||||
CodexProvider(),
|
||||
ChatGPTSupervisorProvider(),
|
||||
)
|
||||
return {item.descriptor.executor_type: item for item in providers}
|
||||
@@ -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))
|
||||
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
|
||||
|
||||
running = self.control_plane.transition_task(
|
||||
if task.state != TaskState.APPROVED:
|
||||
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||
|
||||
authorization_id = self._authorize_dispatch(
|
||||
context,
|
||||
task=task,
|
||||
agent=agent,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
runtime = agent.runtime.strip().lower()
|
||||
prepared: DispatchRecord | None = None
|
||||
|
||||
try:
|
||||
if runtime == "openclaw":
|
||||
command = self._create_openclaw_command(context, task)
|
||||
prepared = self.dispatch_repository.create(
|
||||
DispatchRecord.create(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=command.command_id,
|
||||
trace_id=context.trace_id,
|
||||
provenance_id=persisted_provenance,
|
||||
run_id=persisted_run,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
)
|
||||
self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
|
||||
runtime = agent.runtime.strip().lower()
|
||||
try:
|
||||
if runtime == "openclaw":
|
||||
record = self._dispatch_openclaw(
|
||||
context,
|
||||
agent,
|
||||
running,
|
||||
timeout_seconds=timeout_seconds,
|
||||
self.dispatch_repository.update(
|
||||
prepared.dispatch_id,
|
||||
tenant_id=prepared.tenant_id,
|
||||
state=DispatchState.RUNNING,
|
||||
)
|
||||
elif runtime == "hermes":
|
||||
record = self._dispatch_hermes(context, agent, running)
|
||||
else:
|
||||
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")
|
||||
if task.state != TaskState.RUNNING:
|
||||
return task
|
||||
if result.state == CommandState.COMPLETED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
dispatch = self.dispatch_repository.get(
|
||||
dispatch_id, tenant_id=context.tenant_id
|
||||
)
|
||||
if result.state in {
|
||||
self._assert_dispatch_context(
|
||||
dispatch,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=result.command_id,
|
||||
tenant_id=result.tenant_id,
|
||||
task_id=result.task_id,
|
||||
trace_id=result.trace_id,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
result_digest = digest_payload(
|
||||
{
|
||||
"command_id": result.command_id,
|
||||
"state": result.state.value,
|
||||
"output": result.output,
|
||||
"error": result.error,
|
||||
"runtime_id": result.runtime_id,
|
||||
"attempts": result.attempts,
|
||||
"finished_at": result.finished_at,
|
||||
}
|
||||
)
|
||||
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||
if dispatch.result_digest != result_digest:
|
||||
raise DispatchConflict("terminal dispatch received a different result")
|
||||
return self.control_plane.get_task(context, dispatch.task_id)
|
||||
|
||||
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||
if task.state != TaskState.RUNNING:
|
||||
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||
if result.state == CommandState.COMPLETED:
|
||||
target = TaskState.COMPLETED
|
||||
values = {"result": result.output or {}}
|
||||
dispatch_state = DispatchState.COMPLETED
|
||||
elif result.state in {
|
||||
CommandState.FAILED,
|
||||
CommandState.TIMED_OUT,
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": result.state.value.lower()},
|
||||
)
|
||||
if result.state == CommandState.CANCELLED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
target = TaskState.FAILED
|
||||
values = {
|
||||
"error": result.error or {"code": result.state.value.lower()}
|
||||
}
|
||||
dispatch_state = DispatchState.FAILED
|
||||
elif result.state == CommandState.CANCELLED:
|
||||
target = TaskState.CANCELLED
|
||||
values = {}
|
||||
dispatch_state = DispatchState.CANCELLED
|
||||
else:
|
||||
self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
state=DispatchState.RUNNING,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
return task
|
||||
|
||||
updated_task = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=target,
|
||||
expected_revision=task.revision,
|
||||
**values,
|
||||
)
|
||||
self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
state=dispatch_state,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
return updated_task
|
||||
|
||||
def reconcile_hermes(
|
||||
self,
|
||||
context: RequestContext,
|
||||
result: HermesResult,
|
||||
*,
|
||||
dispatch_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> TaskRecord:
|
||||
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(
|
||||
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=TaskState.COMPLETED,
|
||||
target_state=target,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
**values,
|
||||
)
|
||||
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"},
|
||||
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,
|
||||
)
|
||||
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
|
||||
return updated_task
|
||||
|
||||
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||
return {
|
||||
task_id: record
|
||||
for (record_tenant, task_id), record in self._dispatches.items()
|
||||
if record_tenant == tenant_id
|
||||
item.task_id: item
|
||||
for item in self.dispatch_repository.list(tenant_id=tenant_id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
|
||||
|
||||
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
|
||||
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
|
||||
重放或上下文不匹配都默认拒绝。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
|
||||
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("timestamp is required")
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def digest_payload(value: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _validate_digest(value: str) -> str:
|
||||
normalized = required(value, "request_digest").lower()
|
||||
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
|
||||
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
|
||||
return normalized
|
||||
|
||||
|
||||
class ExecutionPath(str, Enum):
|
||||
CODE_CHANGE = "code_change"
|
||||
GIT_OPERATION = "git_operation"
|
||||
DEPLOYMENT = "deployment"
|
||||
RUNTIME_EXECUTE = "runtime_execute"
|
||||
EVIDENCE_WRITE = "evidence_write"
|
||||
ROLLBACK = "rollback"
|
||||
AUDIT = "audit"
|
||||
|
||||
|
||||
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
|
||||
|
||||
|
||||
class GitOperation(str, Enum):
|
||||
READ_REF = "read_ref"
|
||||
FETCH = "fetch"
|
||||
CREATE_BRANCH = "create_branch"
|
||||
COMMIT = "commit"
|
||||
MERGE = "merge"
|
||||
PUSH = "push"
|
||||
UPDATE_EVIDENCE = "update_evidence"
|
||||
|
||||
|
||||
class ProvenanceStatus(str, Enum):
|
||||
ISSUED = "issued"
|
||||
VERIFIED = "verified"
|
||||
USED = "used"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class ProvenanceError(RuntimeError):
|
||||
"""Run Provenance 基础错误。"""
|
||||
|
||||
|
||||
class ProvenanceDenied(ProvenanceError):
|
||||
"""策略拒绝当前操作。"""
|
||||
|
||||
|
||||
class ProvenanceNotFound(ProvenanceDenied):
|
||||
"""未找到 Provenance。"""
|
||||
|
||||
|
||||
class ProvenanceSignatureError(ProvenanceDenied):
|
||||
"""签名无效。"""
|
||||
|
||||
|
||||
class ProvenanceExpired(ProvenanceDenied):
|
||||
"""Provenance 已过期。"""
|
||||
|
||||
|
||||
class ProvenanceRevoked(ProvenanceDenied):
|
||||
"""Provenance 已撤销。"""
|
||||
|
||||
|
||||
class ProvenanceReplay(ProvenanceDenied):
|
||||
"""检测到相同授权摘要重放。"""
|
||||
|
||||
|
||||
class Signer(Protocol):
|
||||
key_id: str
|
||||
|
||||
def sign(self, payload: bytes) -> str: ...
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def verify(self, payload: bytes, signature: str) -> bool: ...
|
||||
|
||||
|
||||
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover - depends on deployment image
|
||||
raise RuntimeError(
|
||||
"cryptography package with Ed25519 support is required"
|
||||
) from exc
|
||||
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
def _check_private_key_file(path: Path) -> bytes:
|
||||
resolved = path.resolve()
|
||||
metadata = resolved.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("private key reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("private key file permissions must be 0600 or stricter")
|
||||
return resolved.read_bytes()
|
||||
|
||||
|
||||
def _check_public_key_file(path: Path) -> bytes:
|
||||
resolved = path.resolve()
|
||||
metadata = resolved.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("public key reference is not a regular file")
|
||||
if metadata.st_mode & 0o022:
|
||||
raise RuntimeError("public key file must not be group/world writable")
|
||||
return resolved.read_bytes()
|
||||
|
||||
|
||||
class Ed25519PrivateKeySigner:
|
||||
"""Ed25519 Owner/Control Plane 签发器。"""
|
||||
|
||||
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||
self.private_key = private_key
|
||||
self.key_id = required(key_id, "key_id")
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
value = _check_private_key_file(Path(path))
|
||||
if value.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(value, password=None)
|
||||
elif len(value) == 32:
|
||||
key = private_cls.from_private_bytes(value)
|
||||
else:
|
||||
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
|
||||
if not isinstance(key, private_cls):
|
||||
raise RuntimeError("private key is not Ed25519")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def sign(self, payload: bytes) -> str:
|
||||
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
|
||||
|
||||
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
|
||||
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||
|
||||
|
||||
class Ed25519PublicKeyVerifier:
|
||||
"""Ed25519 签名验证器。"""
|
||||
|
||||
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
if not isinstance(public_key, public_cls):
|
||||
raise RuntimeError("public key is not Ed25519")
|
||||
self.public_key = public_key
|
||||
self.key_id = required(key_id, "key_id")
|
||||
raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
value = _check_public_key_file(Path(path))
|
||||
if value.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_public_key(value)
|
||||
elif len(value) == 32:
|
||||
key = public_cls.from_public_bytes(value)
|
||||
else:
|
||||
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
|
||||
if not isinstance(key, public_cls):
|
||||
raise RuntimeError("public key is not Ed25519")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def verify(self, payload: bytes, signature: str) -> bool:
|
||||
try:
|
||||
decoded = base64.b64decode(required(signature, "signature"), validate=True)
|
||||
self.public_key.verify(decoded, bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunProvenance:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
issuer: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
allowed_paths: Sequence[str]
|
||||
allowed_git_operations: Sequence[str]
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
nonce: str
|
||||
signature: str = ""
|
||||
schema_version: str = PROVENANCE_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"issuer",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"policy_version",
|
||||
"key_id",
|
||||
"nonce",
|
||||
"schema_version",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != PROVENANCE_VERSION:
|
||||
raise ValueError(f"unsupported schema_version: {self.schema_version}")
|
||||
issued = parse_timestamp(self.issued_at)
|
||||
expires = parse_timestamp(self.expires_at)
|
||||
if expires <= issued:
|
||||
raise ValueError("expires_at must be later than issued_at")
|
||||
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
|
||||
operations = tuple(
|
||||
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||
)
|
||||
object.__setattr__(self, "allowed_paths", paths)
|
||||
object.__setattr__(self, "allowed_git_operations", operations)
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
"issuer": self.issuer,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"principal_id": self.principal_id,
|
||||
"allowed_paths": list(self.allowed_paths),
|
||||
"allowed_git_operations": list(self.allowed_git_operations),
|
||||
"issued_at": self.issued_at,
|
||||
"expires_at": self.expires_at,
|
||||
"policy_version": self.policy_version,
|
||||
"key_id": self.key_id,
|
||||
"nonce": self.nonce,
|
||||
}
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return canonical_json_bytes(self.unsigned_dict())
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = self.unsigned_dict()
|
||||
value["signature"] = self.signature
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
class ProvenanceIssuer:
|
||||
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
|
||||
|
||||
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
|
||||
self.signer = signer
|
||||
self.store = store
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
issuer: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
|
||||
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
|
||||
ttl_seconds: int = 3600,
|
||||
policy_version: str = PRODUCTION_POLICY_VERSION,
|
||||
provenance_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RunProvenance:
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError("ttl_seconds must be positive")
|
||||
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
|
||||
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
|
||||
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
|
||||
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
|
||||
raise ProvenanceDenied(
|
||||
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
|
||||
)
|
||||
normalized_operations = tuple(
|
||||
sorted({GitOperation(item).value for item in allowed_git_operations})
|
||||
)
|
||||
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
|
||||
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
|
||||
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
unsigned = RunProvenance(
|
||||
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
|
||||
run_id=run_id,
|
||||
issuer=issuer,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
allowed_paths=tuple(normalized_paths),
|
||||
allowed_git_operations=normalized_operations,
|
||||
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=(issued + timedelta(seconds=ttl_seconds))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
policy_version=policy_version,
|
||||
key_id=self.signer.key_id,
|
||||
nonce=nonce or uuid.uuid4().hex,
|
||||
)
|
||||
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||
self.store.save_issued(signed)
|
||||
return signed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceAuthorization:
|
||||
authorization_id: str
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
git_operation: str | None
|
||||
request_digest: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
public_key_fingerprint: str
|
||||
authorized_at: str = field(default_factory=utc_now)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SQLiteProvenanceStore:
|
||||
"""持久化 Provenance、状态、授权使用和不可变事件。"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._connection = sqlite3.connect(
|
||||
str(self.path), timeout=30.0, check_same_thread=False
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA synchronous=FULL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
self._connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS provenance_records (
|
||||
provenance_id TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_usage (
|
||||
authorization_id TEXT PRIMARY KEY,
|
||||
provenance_id TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
git_operation TEXT NOT NULL DEFAULT '',
|
||||
request_digest TEXT NOT NULL,
|
||||
authorized_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
|
||||
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
provenance_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
|
||||
ON provenance_usage(provenance_id, authorized_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
|
||||
ON provenance_events(provenance_id, created_at);
|
||||
"""
|
||||
)
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
def _event(
|
||||
self,
|
||||
provenance_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"pevt_{uuid.uuid4().hex}",
|
||||
provenance_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def save_issued(self, provenance: RunProvenance) -> None:
|
||||
payload = json.dumps(
|
||||
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
now = utc_now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
prior = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||
(provenance.provenance_id,),
|
||||
).fetchone()
|
||||
if prior is not None:
|
||||
if prior["payload_json"] != payload:
|
||||
raise ProvenanceDenied("provenance_id reused with different payload")
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
|
||||
(
|
||||
provenance.provenance_id,
|
||||
payload,
|
||||
ProvenanceStatus.ISSUED.value,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(provenance.provenance_id, "issued")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def get(self, provenance_id: str) -> RunProvenance:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance not found")
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProvenanceError("stored provenance payload is invalid")
|
||||
return RunProvenance.from_dict(value)
|
||||
|
||||
def status(self, provenance_id: str) -> ProvenanceStatus:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT status FROM provenance_records WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance not found")
|
||||
return ProvenanceStatus(row["status"])
|
||||
|
||||
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(provenance_id)
|
||||
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
|
||||
next_status = (
|
||||
ProvenanceStatus.USED.value
|
||||
if current == ProvenanceStatus.USED
|
||||
else ProvenanceStatus.VERIFIED.value
|
||||
)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
|
||||
(next_status, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(
|
||||
provenance_id,
|
||||
"verified",
|
||||
{"public_key_fingerprint": fingerprint},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def mark_expired(self, provenance_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(provenance_id)
|
||||
if current == ProvenanceStatus.REVOKED:
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(provenance_id, "expired")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self.get(provenance_id)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(
|
||||
provenance_id,
|
||||
"revoked",
|
||||
{
|
||||
"reason": required(reason, "reason"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
|
||||
receipt = json.dumps(
|
||||
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(authorization.provenance_id)
|
||||
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"provenance state is {current.value}")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
authorization.authorization_id,
|
||||
authorization.provenance_id,
|
||||
authorization.execution_path,
|
||||
authorization.git_operation or "",
|
||||
authorization.request_digest,
|
||||
authorization.authorized_at,
|
||||
receipt,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ProvenanceReplay(
|
||||
"identical provenance path/operation/request digest already used"
|
||||
) from exc
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(
|
||||
ProvenanceStatus.USED.value,
|
||||
authorization.authorized_at,
|
||||
authorization.provenance_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
authorization.provenance_id,
|
||||
"used",
|
||||
{
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"execution_path": authorization.execution_path,
|
||||
"git_operation": authorization.git_operation,
|
||||
"request_digest": authorization.request_digest,
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def authorization_count(self, provenance_id: str) -> int:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
return int(row["total"])
|
||||
|
||||
def events(self, provenance_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._connection.execute(
|
||||
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_type": row["event_type"],
|
||||
"details": json.loads(row["details_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class ProvenanceVerifier:
|
||||
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SQLiteProvenanceStore,
|
||||
verifiers: Mapping[str, SignatureVerifier],
|
||||
) -> None:
|
||||
if not verifiers:
|
||||
raise ValueError("at least one public key verifier is required")
|
||||
self.store = store
|
||||
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
*,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
execution_path: str,
|
||||
request_digest: str,
|
||||
git_operation: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ProvenanceAuthorization:
|
||||
provenance = self.store.get(provenance_id)
|
||||
status = self.store.status(provenance.provenance_id)
|
||||
if status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("provenance is revoked")
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
if current >= parse_timestamp(provenance.expires_at):
|
||||
self.store.mark_expired(provenance.provenance_id)
|
||||
raise ProvenanceExpired("provenance is expired")
|
||||
verifier = self.verifiers.get(provenance.key_id)
|
||||
if verifier is None:
|
||||
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
|
||||
if not provenance.signature or not verifier.verify(
|
||||
provenance.canonical_bytes(), provenance.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid provenance signature")
|
||||
expected = {
|
||||
"run_id": required(run_id, "run_id"),
|
||||
"tenant_id": required(tenant_id, "tenant_id"),
|
||||
"task_id": required(task_id, "task_id"),
|
||||
"agent_id": required(agent_id, "agent_id"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
}
|
||||
for field_name, expected_value in expected.items():
|
||||
if getattr(provenance, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"provenance {field_name} mismatch")
|
||||
path = ExecutionPath(execution_path).value
|
||||
if path not in provenance.allowed_paths:
|
||||
raise ProvenanceDenied(f"execution path is not allowed: {path}")
|
||||
operation: str | None = None
|
||||
if path == ExecutionPath.GIT_OPERATION.value:
|
||||
if git_operation is None:
|
||||
raise ProvenanceDenied("git operation is required for git_operation path")
|
||||
operation = GitOperation(git_operation).value
|
||||
if operation not in provenance.allowed_git_operations:
|
||||
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||
elif git_operation is not None:
|
||||
raise ProvenanceDenied("git_operation is only valid for git_operation path")
|
||||
digest = _validate_digest(request_digest)
|
||||
self.store.mark_verified(
|
||||
provenance.provenance_id, fingerprint=verifier.fingerprint
|
||||
)
|
||||
authorization = ProvenanceAuthorization(
|
||||
authorization_id=f"pauth_{uuid.uuid4().hex}",
|
||||
provenance_id=provenance.provenance_id,
|
||||
run_id=provenance.run_id,
|
||||
tenant_id=provenance.tenant_id,
|
||||
task_id=provenance.task_id,
|
||||
agent_id=provenance.agent_id,
|
||||
principal_id=provenance.principal_id,
|
||||
execution_path=path,
|
||||
git_operation=operation,
|
||||
request_digest=digest,
|
||||
policy_version=provenance.policy_version,
|
||||
key_id=provenance.key_id,
|
||||
public_key_fingerprint=verifier.fingerprint,
|
||||
)
|
||||
self.store.record_authorization(authorization)
|
||||
return authorization
|
||||
|
||||
|
||||
class ProvenanceControlledWriter:
|
||||
"""七路径统一策略入口并持久写入授权回执。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: ProvenanceVerifier,
|
||||
receipt_root: str | Path,
|
||||
*,
|
||||
writer_id: str = "aios-v2-provenance-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def authorize(self, **values: Any) -> ProvenanceAuthorization:
|
||||
authorization = self.verifier.authorize(**values)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
provenance_id = self._component(authorization.provenance_id)
|
||||
target_dir = self.receipt_root / tenant / provenance_id
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> ProvenanceAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
|
||||
|
||||
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
|
||||
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
|
||||
``issue-workflow`` 与 ``issue-grant``。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceIssuer,
|
||||
SQLiteProvenanceStore,
|
||||
)
|
||||
from .provenance_grants import (
|
||||
GrantIssuer,
|
||||
GrantType,
|
||||
GrantVerifier,
|
||||
SQLiteGrantStore,
|
||||
WorkflowProvenanceIssuer,
|
||||
)
|
||||
from .provenance_operations import (
|
||||
OperationContext,
|
||||
ProvenanceOperationGate,
|
||||
context_from_environment,
|
||||
load_request_file,
|
||||
)
|
||||
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
|
||||
if value and file_path:
|
||||
raise ValueError("request-json and request-file are mutually exclusive")
|
||||
if file_path:
|
||||
return load_request_file(file_path)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, Mapping):
|
||||
raise ValueError("request JSON must be an object")
|
||||
return dict(parsed)
|
||||
|
||||
|
||||
def _csv(value: str | None) -> tuple[str, ...]:
|
||||
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
|
||||
|
||||
|
||||
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
if path:
|
||||
target = Path(path).resolve()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(encoded, encoding="utf-8")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
print(encoded, end="")
|
||||
|
||||
|
||||
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
|
||||
return SQLiteProvenanceStore(args.store_file)
|
||||
|
||||
|
||||
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
|
||||
return SQLiteGrantStore(args.store_file)
|
||||
|
||||
|
||||
def _context(args: argparse.Namespace) -> OperationContext:
|
||||
if args.context_from_env:
|
||||
return context_from_environment()
|
||||
values = {
|
||||
"provenance_id": args.provenance_id,
|
||||
"run_id": args.run_id,
|
||||
"tenant_id": args.tenant_id,
|
||||
"task_id": args.task_id,
|
||||
"agent_id": args.agent_id,
|
||||
"principal_id": args.principal_id,
|
||||
}
|
||||
missing = [name for name, value in values.items() if not str(value or "").strip()]
|
||||
if missing:
|
||||
raise ValueError(f"missing context arguments: {missing}")
|
||||
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||
|
||||
|
||||
def command_issue_legacy(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
provenance = ProvenanceIssuer(signer, store).issue(
|
||||
run_id=args.run_id,
|
||||
issuer=args.issuer,
|
||||
tenant_id=args.tenant_id,
|
||||
task_id=args.task_id,
|
||||
agent_id=args.agent_id,
|
||||
principal_id=args.principal_id,
|
||||
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
|
||||
allowed_git_operations=_csv(args.allowed_git_operations)
|
||||
or tuple(item.value for item in GitOperation),
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
policy_version=args.policy_version,
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"mode": "legacy-full-rights-candidate-only",
|
||||
"provenance": provenance.as_dict(),
|
||||
"status": store.status(provenance.provenance_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_issue_workflow(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
workflow = WorkflowProvenanceIssuer(signer, store).issue(
|
||||
run_id=args.run_id,
|
||||
issuer=args.issuer,
|
||||
tenant_id=args.tenant_id,
|
||||
task_id=args.task_id,
|
||||
agent_id=args.agent_id,
|
||||
principal_id=args.principal_id,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"mode": "least-privilege-workflow",
|
||||
"workflow_provenance": workflow.as_dict(),
|
||||
"status": store.status(workflow.provenance_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_issue_grant(args: argparse.Namespace) -> int:
|
||||
parent_store = _parent_store(args)
|
||||
grant_store = _grant_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
grant = GrantIssuer(signer, parent_store, grant_store).issue(
|
||||
parent_provenance_id=args.parent_provenance_id,
|
||||
grant_type=GrantType(args.grant_type),
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
allowed_git_operations=_csv(args.allowed_git_operations),
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"grant": grant.as_dict(),
|
||||
"status": grant_store.status(grant.grant_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
|
||||
path = ExecutionPath(args.path).value
|
||||
parent_store = _parent_store(args)
|
||||
grant_store = _grant_store(args)
|
||||
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
|
||||
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
trusted,
|
||||
args.receipt_dir,
|
||||
{path: args.grant_id},
|
||||
required_paths=(path,),
|
||||
writer_id=f"aios-v2-cli-{path}",
|
||||
)
|
||||
|
||||
|
||||
def command_authorize(args: argparse.Namespace) -> int:
|
||||
writer = _single_scope_writer(args)
|
||||
context = _context(args)
|
||||
receipt = ProvenanceOperationGate(writer).authorize(
|
||||
context,
|
||||
execution_path=args.path,
|
||||
operation=args.operation,
|
||||
request=_json_object(args.request_json, args.request_file),
|
||||
)
|
||||
_write_json(args.output, {"authorization": receipt.as_dict()})
|
||||
return 0
|
||||
|
||||
|
||||
def command_revoke(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_status(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
provenance = store.get(args.provenance_id)
|
||||
grant_store = _grant_store(args)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"provenance": provenance.as_dict(),
|
||||
"status": store.status(args.provenance_id).value,
|
||||
"authorization_count": store.authorization_count(args.provenance_id),
|
||||
"events": store.events(args.provenance_id),
|
||||
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_grant_revoke(args: argparse.Namespace) -> int:
|
||||
store = _grant_store(args)
|
||||
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_grant_status(args: argparse.Namespace) -> int:
|
||||
store = _grant_store(args)
|
||||
grant = store.get(args.grant_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def add_common_store(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--store-file",
|
||||
default=os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
"/var/lib/aios-v2/run-provenance.sqlite3",
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output", default=None)
|
||||
|
||||
|
||||
def add_signing_context(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--private-key-file", required=True)
|
||||
parser.add_argument("--key-id", required=True)
|
||||
|
||||
|
||||
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--issuer", required=True)
|
||||
parser.add_argument("--tenant-id", required=True)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--agent-id", required=True)
|
||||
parser.add_argument("--principal-id", required=True)
|
||||
|
||||
|
||||
def add_context(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--context-from-env", action="store_true")
|
||||
parser.add_argument("--provenance-id")
|
||||
parser.add_argument("--run-id")
|
||||
parser.add_argument("--tenant-id")
|
||||
parser.add_argument("--task-id")
|
||||
parser.add_argument("--agent-id")
|
||||
parser.add_argument("--principal-id")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
|
||||
add_common_store(issue)
|
||||
add_signing_context(issue)
|
||||
add_workflow_identity(issue)
|
||||
issue.add_argument("--allowed-paths")
|
||||
issue.add_argument("--allowed-git-operations")
|
||||
issue.add_argument("--ttl-seconds", type=int, default=3600)
|
||||
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
|
||||
issue.set_defaults(handler=command_issue_legacy)
|
||||
|
||||
workflow = subcommands.add_parser(
|
||||
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
|
||||
)
|
||||
add_common_store(workflow)
|
||||
add_signing_context(workflow)
|
||||
add_workflow_identity(workflow)
|
||||
workflow.add_argument("--ttl-seconds", type=int, default=14400)
|
||||
workflow.set_defaults(handler=command_issue_workflow)
|
||||
|
||||
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
|
||||
add_common_store(grant)
|
||||
add_signing_context(grant)
|
||||
grant.add_argument("--parent-provenance-id", required=True)
|
||||
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
|
||||
grant.add_argument("--allowed-git-operations")
|
||||
grant.add_argument("--ttl-seconds", type=int, default=900)
|
||||
grant.set_defaults(handler=command_issue_grant)
|
||||
|
||||
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
|
||||
add_common_store(authorize)
|
||||
add_context(authorize)
|
||||
authorize.add_argument("--public-key-file", required=True)
|
||||
authorize.add_argument("--key-id", required=True)
|
||||
authorize.add_argument("--grant-id", required=True)
|
||||
authorize.add_argument(
|
||||
"--receipt-dir",
|
||||
default=os.getenv(
|
||||
"AIOS_PROVENANCE_RECEIPT_DIR",
|
||||
"/var/lib/aios-v2/provenance-receipts",
|
||||
),
|
||||
)
|
||||
authorize.add_argument(
|
||||
"--path",
|
||||
required=True,
|
||||
choices=[
|
||||
ExecutionPath.CODE_CHANGE.value,
|
||||
ExecutionPath.DEPLOYMENT.value,
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
ExecutionPath.EVIDENCE_WRITE.value,
|
||||
ExecutionPath.ROLLBACK.value,
|
||||
ExecutionPath.AUDIT.value,
|
||||
],
|
||||
)
|
||||
authorize.add_argument("--operation", required=True)
|
||||
request_group = authorize.add_mutually_exclusive_group()
|
||||
request_group.add_argument("--request-json")
|
||||
request_group.add_argument("--request-file")
|
||||
authorize.set_defaults(handler=command_authorize)
|
||||
|
||||
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
|
||||
add_common_store(revoke)
|
||||
revoke.add_argument("--provenance-id", required=True)
|
||||
revoke.add_argument("--reason", required=True)
|
||||
revoke.add_argument("--principal-id", required=True)
|
||||
revoke.set_defaults(handler=command_revoke)
|
||||
|
||||
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
|
||||
add_common_store(status)
|
||||
status.add_argument("--provenance-id", required=True)
|
||||
status.set_defaults(handler=command_status)
|
||||
|
||||
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
|
||||
add_common_store(grant_revoke)
|
||||
grant_revoke.add_argument("--grant-id", required=True)
|
||||
grant_revoke.add_argument("--reason", required=True)
|
||||
grant_revoke.add_argument("--principal-id", required=True)
|
||||
grant_revoke.set_defaults(handler=command_grant_revoke)
|
||||
|
||||
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
|
||||
add_common_store(grant_status)
|
||||
grant_status.add_argument("--grant-id", required=True)
|
||||
grant_status.set_defaults(handler=command_grant_status)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.handler(args))
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,416 @@
|
||||
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
|
||||
|
||||
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
|
||||
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
|
||||
请求改变 Git Index。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
|
||||
|
||||
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
|
||||
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
|
||||
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
|
||||
|
||||
|
||||
class GitAuthorizationWriter(Protocol):
|
||||
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||
|
||||
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
class ProvenanceGitError(RuntimeError):
|
||||
"""Git 操作被拒绝或执行失败。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitExecutionContext:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCommandResult:
|
||||
operation_id: str
|
||||
operation: str
|
||||
authorization_ids: Sequence[str]
|
||||
command: Sequence[str]
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
head_before: str | None
|
||||
head_after: str | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ProvenanceGitWriter:
|
||||
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: str | Path,
|
||||
writer: GitAuthorizationWriter,
|
||||
*,
|
||||
timeout_seconds: float = 120.0,
|
||||
output_limit: int = 16000,
|
||||
) -> None:
|
||||
self.repo = Path(repo).resolve()
|
||||
self.writer = writer
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.output_limit = int(output_limit)
|
||||
if self.timeout_seconds <= 0 or self.output_limit < 1024:
|
||||
raise ValueError("invalid Git writer limits")
|
||||
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
|
||||
|
||||
@staticmethod
|
||||
def _validate_branch(branch: str) -> str:
|
||||
normalized = str(branch or "").strip()
|
||||
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
|
||||
raise ProvenanceGitError("invalid Git branch name")
|
||||
if normalized in {"main", "master"}:
|
||||
raise ProvenanceGitError("direct main/master operation is prohibited")
|
||||
return normalized
|
||||
|
||||
def _validate_path(self, value: str | Path) -> str:
|
||||
raw = Path(value)
|
||||
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||||
try:
|
||||
relative = candidate.relative_to(self.repo)
|
||||
except ValueError as exc:
|
||||
raise ProvenanceGitError("Git path escapes repository") from exc
|
||||
if str(relative) in {"", "."}:
|
||||
raise ProvenanceGitError("repository root is not a valid file path")
|
||||
return relative.as_posix()
|
||||
|
||||
@staticmethod
|
||||
def _redact(value: str, limit: int) -> str:
|
||||
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
|
||||
text = _TOKENISH.sub(r"\1\2***", text)
|
||||
if len(text) > limit:
|
||||
return text[:limit] + "\n...[truncated]"
|
||||
return text
|
||||
|
||||
def _head(self) -> str | None:
|
||||
process = self._run_raw(["rev-parse", "HEAD"], check=False)
|
||||
return process.stdout.strip() if process.returncode == 0 else None
|
||||
|
||||
def _run_raw(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
check: bool,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
|
||||
safe_env = {
|
||||
**os.environ,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
|
||||
}
|
||||
if env:
|
||||
safe_env.update({str(key): str(value) for key, value in env.items()})
|
||||
process = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
env=safe_env,
|
||||
)
|
||||
if check and process.returncode != 0:
|
||||
raise ProvenanceGitError(
|
||||
self._redact(process.stderr or process.stdout, self.output_limit)
|
||||
or f"Git command failed with exit code {process.returncode}"
|
||||
)
|
||||
return process
|
||||
|
||||
def _path_digest(self, relative: str) -> str:
|
||||
target = self.repo / relative
|
||||
if target.exists():
|
||||
if not target.is_file():
|
||||
raise ProvenanceGitError("Git writer accepts file paths only")
|
||||
hasher = hashlib.sha256()
|
||||
with target.open("rb") as handle:
|
||||
while True:
|
||||
block = handle.read(65536)
|
||||
if not block:
|
||||
break
|
||||
hasher.update(block)
|
||||
return hasher.hexdigest()
|
||||
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
|
||||
if tracked.returncode != 0:
|
||||
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
|
||||
return "deleted"
|
||||
|
||||
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
|
||||
return {path: self._path_digest(path) for path in sorted(set(paths))}
|
||||
|
||||
def _authorize(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
request: Mapping[str, Any],
|
||||
code_change: bool,
|
||||
) -> list[str]:
|
||||
request_digest = digest_payload(
|
||||
{
|
||||
"operation": operation.value,
|
||||
"repo": self.repo.name,
|
||||
"request": dict(request),
|
||||
"head": self._head(),
|
||||
}
|
||||
)
|
||||
ids: list[str] = []
|
||||
try:
|
||||
if code_change:
|
||||
code_authorization = self.writer.authorize_code_change(
|
||||
provenance_id=context.provenance_id,
|
||||
run_id=context.run_id,
|
||||
tenant_id=context.tenant_id,
|
||||
task_id=context.task_id,
|
||||
agent_id=context.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
request_digest=digest_payload(
|
||||
{
|
||||
"git_operation": operation.value,
|
||||
"request_digest": request_digest,
|
||||
}
|
||||
),
|
||||
)
|
||||
ids.append(code_authorization.authorization_id)
|
||||
git_authorization = self.writer.authorize_git_operation(
|
||||
provenance_id=context.provenance_id,
|
||||
run_id=context.run_id,
|
||||
tenant_id=context.tenant_id,
|
||||
task_id=context.task_id,
|
||||
agent_id=context.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
git_operation=operation.value,
|
||||
request_digest=request_digest,
|
||||
)
|
||||
ids.append(git_authorization.authorization_id)
|
||||
return ids
|
||||
except ProvenanceDenied as exc:
|
||||
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
|
||||
|
||||
def _run_authorized(
|
||||
self,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
args: Sequence[str],
|
||||
authorization_ids: Sequence[str],
|
||||
) -> GitCommandResult:
|
||||
started = utc_now()
|
||||
before = self._head()
|
||||
process = self._run_raw(args, check=False)
|
||||
after = self._head()
|
||||
result = GitCommandResult(
|
||||
operation_id=f"gitop_{uuid.uuid4().hex}",
|
||||
operation=operation.value,
|
||||
authorization_ids=tuple(authorization_ids),
|
||||
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
|
||||
exit_code=int(process.returncode),
|
||||
stdout=self._redact(process.stdout, self.output_limit),
|
||||
stderr=self._redact(process.stderr, self.output_limit),
|
||||
head_before=before,
|
||||
head_after=after,
|
||||
started_at=started,
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
if process.returncode != 0:
|
||||
raise ProvenanceGitError(
|
||||
result.stderr or result.stdout or f"Git {operation.value} failed"
|
||||
)
|
||||
return result
|
||||
|
||||
def _execute(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
args: Sequence[str],
|
||||
request: Mapping[str, Any],
|
||||
code_change: bool,
|
||||
) -> GitCommandResult:
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=operation,
|
||||
request=request,
|
||||
code_change=code_change,
|
||||
)
|
||||
return self._run_authorized(
|
||||
operation=operation,
|
||||
args=args,
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
|
||||
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
|
||||
normalized = str(ref or "").strip()
|
||||
if not normalized or any(ch.isspace() for ch in normalized):
|
||||
raise ProvenanceGitError("invalid Git ref")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.READ_REF,
|
||||
args=["rev-parse", "--verify", normalized],
|
||||
request={"ref": normalized},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
remote: str,
|
||||
refspecs: Sequence[str],
|
||||
) -> GitCommandResult:
|
||||
normalized_remote = str(remote or "").strip()
|
||||
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
|
||||
if not normalized_remote or not normalized_refspecs:
|
||||
raise ProvenanceGitError("remote and refspecs are required")
|
||||
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
|
||||
raise ProvenanceGitError(
|
||||
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
|
||||
)
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.FETCH,
|
||||
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
|
||||
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
branch: str,
|
||||
start_point: str,
|
||||
) -> GitCommandResult:
|
||||
normalized = self._validate_branch(branch)
|
||||
start = str(start_point or "").strip()
|
||||
if not start:
|
||||
raise ProvenanceGitError("start_point is required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.CREATE_BRANCH,
|
||||
args=["branch", normalized, start],
|
||||
request={"branch": normalized, "start_point": start},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def commit(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
paths: Sequence[str | Path],
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||
normalized_message = str(message or "").strip()
|
||||
if not normalized_paths or not normalized_message:
|
||||
raise ProvenanceGitError("commit paths and message are required")
|
||||
if len(normalized_message) > 500:
|
||||
raise ProvenanceGitError("commit message is too long")
|
||||
request = {
|
||||
"paths": normalized_paths,
|
||||
"message": normalized_message,
|
||||
"content_manifest": self._content_manifest(normalized_paths),
|
||||
}
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=GitOperation.COMMIT,
|
||||
request=request,
|
||||
code_change=True,
|
||||
)
|
||||
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||
return self._run_authorized(
|
||||
operation=GitOperation.COMMIT,
|
||||
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
|
||||
def merge(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
source_ref: str,
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
source = str(source_ref or "").strip()
|
||||
normalized_message = str(message or "").strip()
|
||||
if not source or not normalized_message:
|
||||
raise ProvenanceGitError("source_ref and message are required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.MERGE,
|
||||
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
|
||||
request={"source_ref": source, "message": normalized_message},
|
||||
code_change=True,
|
||||
)
|
||||
|
||||
def push(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
remote: str,
|
||||
branch: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_remote = str(remote or "").strip()
|
||||
normalized_branch = self._validate_branch(branch)
|
||||
if not normalized_remote:
|
||||
raise ProvenanceGitError("remote is required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.PUSH,
|
||||
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
|
||||
request={"remote": normalized_remote, "branch": normalized_branch},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def update_evidence(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
paths: Sequence[str | Path],
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||
normalized_message = str(message or "").strip()
|
||||
if not normalized_paths or not normalized_message:
|
||||
raise ProvenanceGitError("evidence paths and message are required")
|
||||
if any(not path.startswith(".agent/") for path in normalized_paths):
|
||||
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
|
||||
request = {
|
||||
"paths": normalized_paths,
|
||||
"message": normalized_message,
|
||||
"content_manifest": self._content_manifest(normalized_paths),
|
||||
}
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=GitOperation.UPDATE_EVIDENCE,
|
||||
request=request,
|
||||
code_change=False,
|
||||
)
|
||||
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||
return self._run_authorized(
|
||||
operation=GitOperation.UPDATE_EVIDENCE,
|
||||
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
@@ -0,0 +1,744 @@
|
||||
"""AIOS V2 最小权限 Run Provenance 子 Grant。
|
||||
|
||||
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
|
||||
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
|
||||
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import (
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceNotFound,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
Signer,
|
||||
SignatureVerifier,
|
||||
_validate_digest,
|
||||
canonical_json_bytes,
|
||||
parse_timestamp,
|
||||
required,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
|
||||
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
|
||||
|
||||
|
||||
class GrantType(str, Enum):
|
||||
CODE_CHANGE = "code_change"
|
||||
GIT = "git"
|
||||
DEPLOYMENT = "deployment"
|
||||
RUNTIME = "runtime"
|
||||
EVIDENCE = "evidence"
|
||||
ROLLBACK = "rollback"
|
||||
AUDIT = "audit"
|
||||
|
||||
|
||||
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
|
||||
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
|
||||
GrantType.GIT: ExecutionPath.GIT_OPERATION,
|
||||
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
|
||||
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
|
||||
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
|
||||
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
|
||||
GrantType.AUDIT: ExecutionPath.AUDIT,
|
||||
}
|
||||
|
||||
|
||||
class GrantStatus(str, Enum):
|
||||
ISSUED = "issued"
|
||||
VERIFIED = "verified"
|
||||
USED = "used"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceGrant:
|
||||
grant_id: str
|
||||
parent_provenance_id: str
|
||||
grant_type: GrantType
|
||||
execution_path: ExecutionPath
|
||||
run_id: str
|
||||
issuer: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
allowed_git_operations: Sequence[str]
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
nonce: str
|
||||
signature: str = ""
|
||||
schema_version: str = GRANT_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"grant_id",
|
||||
"parent_provenance_id",
|
||||
"run_id",
|
||||
"issuer",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"policy_version",
|
||||
"key_id",
|
||||
"nonce",
|
||||
"schema_version",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != GRANT_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported grant schema: {self.schema_version}")
|
||||
grant_type = GrantType(self.grant_type)
|
||||
path = ExecutionPath(self.execution_path)
|
||||
if GRANT_PATHS[grant_type] != path:
|
||||
raise ValueError("grant_type does not match execution_path")
|
||||
object.__setattr__(self, "grant_type", grant_type)
|
||||
object.__setattr__(self, "execution_path", path)
|
||||
operations = tuple(
|
||||
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||
)
|
||||
if grant_type == GrantType.GIT and not operations:
|
||||
raise ValueError("Git Grant requires explicit Git operations")
|
||||
if grant_type != GrantType.GIT and operations:
|
||||
raise ValueError("only Git Grant may contain Git operations")
|
||||
object.__setattr__(self, "allowed_git_operations", operations)
|
||||
issued = parse_timestamp(self.issued_at)
|
||||
expires = parse_timestamp(self.expires_at)
|
||||
if expires <= issued:
|
||||
raise ValueError("grant expires_at must be later than issued_at")
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"grant_id": self.grant_id,
|
||||
"parent_provenance_id": self.parent_provenance_id,
|
||||
"grant_type": self.grant_type.value,
|
||||
"execution_path": self.execution_path.value,
|
||||
"run_id": self.run_id,
|
||||
"issuer": self.issuer,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"principal_id": self.principal_id,
|
||||
"allowed_git_operations": list(self.allowed_git_operations),
|
||||
"issued_at": self.issued_at,
|
||||
"expires_at": self.expires_at,
|
||||
"policy_version": self.policy_version,
|
||||
"key_id": self.key_id,
|
||||
"nonce": self.nonce,
|
||||
}
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return canonical_json_bytes(self.unsigned_dict())
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = self.unsigned_dict()
|
||||
value["signature"] = self.signature
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrantAuthorization:
|
||||
authorization_id: str
|
||||
grant_id: str
|
||||
parent_provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
git_operation: str | None
|
||||
request_digest: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
public_key_fingerprint: str
|
||||
authorized_at: str = field(default_factory=utc_now)
|
||||
|
||||
@property
|
||||
def provenance_id(self) -> str:
|
||||
return self.parent_provenance_id
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class WorkflowProvenanceIssuer:
|
||||
"""签发不携带操作权限的父 Workflow Provenance。"""
|
||||
|
||||
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
|
||||
self._issuer = ProvenanceIssuer(signer, store)
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
issuer: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
ttl_seconds: int = 14400,
|
||||
provenance_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RunProvenance:
|
||||
return self._issuer.issue(
|
||||
run_id=run_id,
|
||||
issuer=issuer,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
allowed_paths=(),
|
||||
allowed_git_operations=(),
|
||||
ttl_seconds=ttl_seconds,
|
||||
policy_version=WORKFLOW_POLICY_VERSION,
|
||||
provenance_id=provenance_id,
|
||||
nonce=nonce,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
class SQLiteGrantStore:
|
||||
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._connection = sqlite3.connect(
|
||||
str(self.path), timeout=30.0, check_same_thread=False
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA synchronous=FULL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
self._connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS provenance_grants (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
parent_provenance_id TEXT NOT NULL,
|
||||
grant_type TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_parent
|
||||
ON provenance_grants(parent_provenance_id, grant_type, status);
|
||||
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
|
||||
authorization_id TEXT PRIMARY KEY,
|
||||
grant_id TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
git_operation TEXT NOT NULL DEFAULT '',
|
||||
request_digest TEXT NOT NULL,
|
||||
authorized_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
UNIQUE(grant_id, execution_path, git_operation, request_digest),
|
||||
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_grant_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
grant_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
def _event(
|
||||
self,
|
||||
grant_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"gevt_{uuid.uuid4().hex}",
|
||||
grant_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def save_issued(self, grant: ProvenanceGrant) -> None:
|
||||
payload = json.dumps(
|
||||
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
now = utc_now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
(
|
||||
grant.grant_id,
|
||||
grant.parent_provenance_id,
|
||||
grant.grant_type.value,
|
||||
grant.execution_path.value,
|
||||
payload,
|
||||
GrantStatus.ISSUED.value,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(grant.grant_id, "issued")
|
||||
self._connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
self._connection.rollback()
|
||||
raise ProvenanceDenied("grant_id already exists") from exc
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def get(self, grant_id: str) -> ProvenanceGrant:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
|
||||
(required(grant_id, "grant_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance grant not found")
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProvenanceDenied("stored grant is invalid")
|
||||
return ProvenanceGrant.from_dict(value)
|
||||
|
||||
def status(self, grant_id: str) -> GrantStatus:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT status FROM provenance_grants WHERE grant_id = ?",
|
||||
(required(grant_id, "grant_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance grant not found")
|
||||
return GrantStatus(row["status"])
|
||||
|
||||
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(grant_id)
|
||||
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
|
||||
next_status = (
|
||||
GrantStatus.USED.value
|
||||
if current == GrantStatus.USED
|
||||
else GrantStatus.VERIFIED.value
|
||||
)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
|
||||
(next_status, utc_now(), grant_id),
|
||||
)
|
||||
self._event(
|
||||
grant_id,
|
||||
"verified",
|
||||
{"public_key_fingerprint": fingerprint},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def mark_expired(self, grant_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if self.status(grant_id) == GrantStatus.REVOKED:
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
|
||||
)
|
||||
self._event(grant_id, "expired")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self.get(grant_id)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.REVOKED.value, utc_now(), grant_id),
|
||||
)
|
||||
self._event(
|
||||
grant_id,
|
||||
"revoked",
|
||||
{
|
||||
"reason": required(reason, "reason"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def record_authorization(self, authorization: GrantAuthorization) -> None:
|
||||
receipt = json.dumps(
|
||||
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(authorization.grant_id)
|
||||
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"grant state is {current.value}")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
authorization.authorization_id,
|
||||
authorization.grant_id,
|
||||
authorization.execution_path,
|
||||
authorization.git_operation or "",
|
||||
authorization.request_digest,
|
||||
authorization.authorized_at,
|
||||
receipt,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ProvenanceReplay(
|
||||
"identical grant path/operation/request digest already used"
|
||||
) from exc
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
|
||||
)
|
||||
self._event(
|
||||
authorization.grant_id,
|
||||
"used",
|
||||
{
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"execution_path": authorization.execution_path,
|
||||
"git_operation": authorization.git_operation,
|
||||
"request_digest": authorization.request_digest,
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
|
||||
with self._lock:
|
||||
rows = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
|
||||
(required(parent_provenance_id, "parent_provenance_id"),),
|
||||
).fetchall()
|
||||
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
|
||||
|
||||
|
||||
class GrantIssuer:
|
||||
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer: Signer,
|
||||
parent_store: SQLiteProvenanceStore,
|
||||
grant_store: SQLiteGrantStore,
|
||||
) -> None:
|
||||
self.signer = signer
|
||||
self.parent_store = parent_store
|
||||
self.grant_store = grant_store
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
parent_provenance_id: str,
|
||||
grant_type: GrantType | str,
|
||||
ttl_seconds: int = 900,
|
||||
allowed_git_operations: Sequence[str] = (),
|
||||
grant_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ProvenanceGrant:
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError("grant ttl_seconds must be positive")
|
||||
parent = self.parent_store.get(parent_provenance_id)
|
||||
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||
raise ProvenanceDenied("child grants require a workflow provenance parent")
|
||||
if parent.allowed_paths or parent.allowed_git_operations:
|
||||
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
|
||||
parent_status = self.parent_store.status(parent.provenance_id)
|
||||
if parent_status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
parent_expiry = parse_timestamp(parent.expires_at)
|
||||
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
|
||||
if expires <= issued:
|
||||
raise ProvenanceExpired("workflow provenance has expired")
|
||||
normalized_type = GrantType(grant_type)
|
||||
operations = tuple(allowed_git_operations)
|
||||
unsigned = ProvenanceGrant(
|
||||
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
|
||||
parent_provenance_id=parent.provenance_id,
|
||||
grant_type=normalized_type,
|
||||
execution_path=GRANT_PATHS[normalized_type],
|
||||
run_id=parent.run_id,
|
||||
issuer=parent.issuer,
|
||||
tenant_id=parent.tenant_id,
|
||||
task_id=parent.task_id,
|
||||
agent_id=parent.agent_id,
|
||||
principal_id=parent.principal_id,
|
||||
allowed_git_operations=operations,
|
||||
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=expires.isoformat().replace("+00:00", "Z"),
|
||||
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
|
||||
key_id=self.signer.key_id,
|
||||
nonce=nonce or uuid.uuid4().hex,
|
||||
)
|
||||
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||
self.grant_store.save_issued(signed)
|
||||
return signed
|
||||
|
||||
|
||||
class GrantVerifier:
|
||||
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_store: SQLiteProvenanceStore,
|
||||
grant_store: SQLiteGrantStore,
|
||||
verifiers: Mapping[str, SignatureVerifier],
|
||||
) -> None:
|
||||
if not verifiers:
|
||||
raise ValueError("at least one trusted verifier is required")
|
||||
self.parent_store = parent_store
|
||||
self.grant_store = grant_store
|
||||
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||
|
||||
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
|
||||
status = self.parent_store.status(parent.provenance_id)
|
||||
if status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||
if now >= parse_timestamp(parent.expires_at):
|
||||
self.parent_store.mark_expired(parent.provenance_id)
|
||||
raise ProvenanceExpired("workflow provenance is expired")
|
||||
verifier = self.verifiers.get(parent.key_id)
|
||||
if verifier is None or not parent.signature or not verifier.verify(
|
||||
parent.canonical_bytes(), parent.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid workflow provenance signature")
|
||||
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||
raise ProvenanceDenied("parent is not a workflow provenance")
|
||||
if parent.allowed_paths or parent.allowed_git_operations:
|
||||
raise ProvenanceDenied("workflow provenance must not contain operation rights")
|
||||
return verifier
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
*,
|
||||
grant_id: str,
|
||||
parent_provenance_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
execution_path: str,
|
||||
request_digest: str,
|
||||
git_operation: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> GrantAuthorization:
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
parent = self.parent_store.get(parent_provenance_id)
|
||||
self._verify_parent(parent, now=current)
|
||||
grant = self.grant_store.get(grant_id)
|
||||
grant_status = self.grant_store.status(grant.grant_id)
|
||||
if grant_status == GrantStatus.REVOKED:
|
||||
raise ProvenanceRevoked("provenance grant is revoked")
|
||||
if current >= parse_timestamp(grant.expires_at):
|
||||
self.grant_store.mark_expired(grant.grant_id)
|
||||
raise ProvenanceExpired("provenance grant is expired")
|
||||
verifier = self.verifiers.get(grant.key_id)
|
||||
if verifier is None or not grant.signature or not verifier.verify(
|
||||
grant.canonical_bytes(), grant.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid provenance grant signature")
|
||||
expected = {
|
||||
"parent_provenance_id": parent.provenance_id,
|
||||
"run_id": required(run_id, "run_id"),
|
||||
"tenant_id": required(tenant_id, "tenant_id"),
|
||||
"task_id": required(task_id, "task_id"),
|
||||
"agent_id": required(agent_id, "agent_id"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
}
|
||||
for field_name, expected_value in expected.items():
|
||||
if getattr(grant, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"grant {field_name} mismatch")
|
||||
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"workflow {field_name} mismatch")
|
||||
path = ExecutionPath(execution_path)
|
||||
if grant.execution_path != path:
|
||||
raise ProvenanceDenied(
|
||||
f"grant permits {grant.execution_path.value}, not {path.value}"
|
||||
)
|
||||
operation: str | None = None
|
||||
if path == ExecutionPath.GIT_OPERATION:
|
||||
if git_operation is None:
|
||||
raise ProvenanceDenied("Git operation is required")
|
||||
operation = GitOperation(git_operation).value
|
||||
if operation not in grant.allowed_git_operations:
|
||||
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||
elif git_operation is not None:
|
||||
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
|
||||
digest = _validate_digest(request_digest)
|
||||
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
|
||||
authorization = GrantAuthorization(
|
||||
authorization_id=f"gauth_{uuid.uuid4().hex}",
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=parent.provenance_id,
|
||||
run_id=grant.run_id,
|
||||
tenant_id=grant.tenant_id,
|
||||
task_id=grant.task_id,
|
||||
agent_id=grant.agent_id,
|
||||
principal_id=grant.principal_id,
|
||||
execution_path=path.value,
|
||||
git_operation=operation,
|
||||
request_digest=digest,
|
||||
policy_version=grant.policy_version,
|
||||
key_id=grant.key_id,
|
||||
public_key_fingerprint=verifier.fingerprint,
|
||||
)
|
||||
self.grant_store.record_authorization(authorization)
|
||||
return authorization
|
||||
|
||||
|
||||
class LeastPrivilegeProvenanceWriter:
|
||||
"""按路径选择独立子 Grant 的生产 Writer。
|
||||
|
||||
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
|
||||
配置映射,调用方无法通过请求体临时扩大权限。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: GrantVerifier,
|
||||
receipt_root: str | Path,
|
||||
grant_ids: Mapping[str, str],
|
||||
*,
|
||||
writer_id: str = "aios-v2-least-privilege-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
self.grant_ids = {
|
||||
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
|
||||
for path, grant_id in grant_ids.items()
|
||||
}
|
||||
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
|
||||
if missing:
|
||||
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||
path = ExecutionPath(values["execution_path"]).value
|
||||
grant_id = self.grant_ids[path]
|
||||
parent_provenance_id = values.pop("provenance_id")
|
||||
authorization = self.verifier.authorize(
|
||||
grant_id=grant_id,
|
||||
parent_provenance_id=parent_provenance_id,
|
||||
**values,
|
||||
)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
parent = self._component(authorization.parent_provenance_id)
|
||||
grant = self._component(authorization.grant_id)
|
||||
target_dir = self.receipt_root / tenant / parent / grant
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .provenance import (
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
ProvenanceAuthorization,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceVerifier,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
|
||||
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
APPLICATION_PROVENANCE_SCOPE = (
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
ExecutionPath.EVIDENCE_WRITE.value,
|
||||
ExecutionPath.AUDIT.value,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationWriter(Protocol):
|
||||
def authorize_deployment(self, **values: Any) -> Any: ...
|
||||
def authorize_rollback(self, **values: Any) -> Any: ...
|
||||
def authorize_audit(self, **values: Any) -> Any: ...
|
||||
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationContext:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationReceipt:
|
||||
operation: str
|
||||
request_digest: str
|
||||
authorization_id: str
|
||||
provenance_id: str
|
||||
grant_id: str | None
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
authorized_at: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ProvenanceOperationGate:
|
||||
"""部署、回滚、审计和运行操作的统一授权入口。"""
|
||||
|
||||
def __init__(self, writer: AuthorizationWriter) -> None:
|
||||
self.writer = writer
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
context: OperationContext,
|
||||
*,
|
||||
execution_path: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
) -> OperationReceipt:
|
||||
path = ExecutionPath(execution_path).value
|
||||
normalized_operation = str(operation or "").strip()
|
||||
if not normalized_operation:
|
||||
raise ValueError("operation is required")
|
||||
digest = digest_payload(
|
||||
{
|
||||
"operation": normalized_operation,
|
||||
"execution_path": path,
|
||||
"request": dict(request),
|
||||
}
|
||||
)
|
||||
values = {
|
||||
"provenance_id": context.provenance_id,
|
||||
"run_id": context.run_id,
|
||||
"tenant_id": context.tenant_id,
|
||||
"task_id": context.task_id,
|
||||
"agent_id": context.agent_id,
|
||||
"principal_id": context.principal_id,
|
||||
"request_digest": digest,
|
||||
}
|
||||
if path == ExecutionPath.DEPLOYMENT.value:
|
||||
authorization = self.writer.authorize_deployment(**values)
|
||||
elif path == ExecutionPath.ROLLBACK.value:
|
||||
authorization = self.writer.authorize_rollback(**values)
|
||||
elif path == ExecutionPath.AUDIT.value:
|
||||
authorization = self.writer.authorize_audit(**values)
|
||||
elif path == ExecutionPath.CODE_CHANGE.value:
|
||||
authorization = self.writer.authorize_code_change(**values)
|
||||
elif path == ExecutionPath.EVIDENCE_WRITE.value:
|
||||
authorization = self.writer.authorize_evidence_write(**values)
|
||||
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
|
||||
authorization = self.writer.authorize_runtime_execute(**values)
|
||||
else:
|
||||
raise ProvenanceDenied(
|
||||
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
|
||||
)
|
||||
return self._receipt(normalized_operation, digest, authorization)
|
||||
|
||||
@staticmethod
|
||||
def _receipt(
|
||||
operation: str,
|
||||
request_digest: str,
|
||||
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
|
||||
) -> OperationReceipt:
|
||||
return OperationReceipt(
|
||||
operation=operation,
|
||||
request_digest=request_digest,
|
||||
authorization_id=authorization.authorization_id,
|
||||
provenance_id=authorization.provenance_id,
|
||||
grant_id=getattr(authorization, "grant_id", None),
|
||||
run_id=authorization.run_id,
|
||||
tenant_id=authorization.tenant_id,
|
||||
task_id=authorization.task_id,
|
||||
agent_id=authorization.agent_id,
|
||||
principal_id=authorization.principal_id,
|
||||
execution_path=authorization.execution_path,
|
||||
authorized_at=authorization.authorized_at,
|
||||
)
|
||||
|
||||
|
||||
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
|
||||
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
|
||||
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
|
||||
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
|
||||
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
|
||||
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
|
||||
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
|
||||
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
|
||||
}
|
||||
|
||||
|
||||
def grant_ids_from_environment(
|
||||
*,
|
||||
required_paths: Sequence[str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
paths = tuple(
|
||||
sorted(
|
||||
{
|
||||
ExecutionPath(path).value
|
||||
for path in (required_paths or tuple(item.value for item in ExecutionPath))
|
||||
}
|
||||
)
|
||||
)
|
||||
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
|
||||
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
|
||||
if missing:
|
||||
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
|
||||
return values
|
||||
|
||||
|
||||
def build_provenance_writer_from_env(
|
||||
*,
|
||||
state_dir: str | Path | None = None,
|
||||
required: bool | None = None,
|
||||
required_paths: Sequence[str] | None = None,
|
||||
writer_id: str = "aios-v2-application-grant-writer",
|
||||
) -> AuthorizationWriter | None:
|
||||
"""从环境构建服务级最小权限 Writer。
|
||||
|
||||
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
|
||||
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
|
||||
只允许在显式关闭强制模式的候选/测试环境中使用。
|
||||
"""
|
||||
|
||||
require_value = (
|
||||
bool(required)
|
||||
if required is not None
|
||||
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
|
||||
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
|
||||
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
store_file = os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
|
||||
).strip()
|
||||
receipt_root = os.getenv(
|
||||
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
|
||||
).strip()
|
||||
configured = bool(public_key_file and key_id)
|
||||
if not configured:
|
||||
if require_value:
|
||||
raise RuntimeError(
|
||||
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
|
||||
)
|
||||
return None
|
||||
|
||||
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
|
||||
parent_store = SQLiteProvenanceStore(store_file)
|
||||
normalized_paths = tuple(
|
||||
sorted(
|
||||
{
|
||||
ExecutionPath(path).value
|
||||
for path in (
|
||||
required_paths
|
||||
if required_paths is not None
|
||||
else APPLICATION_PROVENANCE_SCOPE
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if require_value:
|
||||
if not normalized_paths:
|
||||
raise RuntimeError("production Run Provenance requires a non-empty service scope")
|
||||
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
|
||||
grant_store = SQLiteGrantStore(store_file)
|
||||
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
trusted_grants,
|
||||
receipt_root,
|
||||
grant_ids,
|
||||
required_paths=normalized_paths,
|
||||
writer_id=writer_id,
|
||||
)
|
||||
|
||||
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
|
||||
return ProvenanceControlledWriter(legacy, receipt_root)
|
||||
|
||||
|
||||
def context_from_environment() -> OperationContext:
|
||||
values = {
|
||||
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
|
||||
"run_id": os.getenv("AIOS_RUN_ID", ""),
|
||||
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
|
||||
"task_id": os.getenv("AIOS_TASK_ID", ""),
|
||||
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
|
||||
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
|
||||
}
|
||||
missing = [name for name, value in values.items() if not str(value).strip()]
|
||||
if missing:
|
||||
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
|
||||
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||
|
||||
|
||||
def load_request_file(path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("request file must contain a JSON object")
|
||||
return dict(value)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""按服务范围加载的最小权限 Provenance Writer。
|
||||
|
||||
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
|
||||
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import ExecutionPath, ProvenanceDenied, required
|
||||
from .provenance_grants import GrantAuthorization, GrantVerifier
|
||||
|
||||
|
||||
class ScopedLeastPrivilegeProvenanceWriter:
|
||||
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: GrantVerifier,
|
||||
receipt_root: str | Path,
|
||||
grant_ids: Mapping[str, str],
|
||||
*,
|
||||
required_paths: Sequence[str],
|
||||
writer_id: str = "aios-v2-scoped-grant-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
normalized_required = tuple(
|
||||
sorted({ExecutionPath(path).value for path in required_paths})
|
||||
)
|
||||
if not normalized_required:
|
||||
raise ValueError("required_paths must not be empty")
|
||||
self.required_paths = frozenset(normalized_required)
|
||||
self.grant_ids = {
|
||||
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
|
||||
for path in normalized_required
|
||||
}
|
||||
unexpected = sorted(set(grant_ids) - self.required_paths)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
f"scoped writer received unnecessary Grant mappings: {unexpected}"
|
||||
)
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def _grant_for(self, path: str) -> str:
|
||||
normalized = ExecutionPath(path).value
|
||||
grant_id = self.grant_ids.get(normalized)
|
||||
if grant_id is None:
|
||||
raise ProvenanceDenied(
|
||||
f"this process is not allowed to authorize {normalized}"
|
||||
)
|
||||
return grant_id
|
||||
|
||||
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||
path = ExecutionPath(values["execution_path"]).value
|
||||
grant_id = self._grant_for(path)
|
||||
parent_provenance_id = values.pop("provenance_id")
|
||||
authorization = self.verifier.authorize(
|
||||
grant_id=grant_id,
|
||||
parent_provenance_id=parent_provenance_id,
|
||||
**values,
|
||||
)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
parent = self._component(authorization.parent_provenance_id)
|
||||
grant = self._component(authorization.grant_id)
|
||||
target_dir = self.receipt_root / tenant / parent / grant
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
payload["scope"] = sorted(self.required_paths)
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.CODE_CHANGE.value, **values
|
||||
)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value, **values
|
||||
)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
|
||||
)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
|
||||
)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.ROLLBACK.value, **values
|
||||
)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Durable execution truth registry for externally executed AIOS tasks.
|
||||
|
||||
A task manifest being present in Git is not evidence that an executor discovered
|
||||
or ran it. This registry separates declaration, registration, claim, start,
|
||||
heartbeat and terminal evidence. Release gates must require a terminal success
|
||||
record with immutable evidence hashes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_time(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp requires 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
|
||||
|
||||
|
||||
class ExecutionTruthState(str, Enum):
|
||||
DECLARED = "DECLARED"
|
||||
REGISTERED = "REGISTERED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
NOT_STARTED = "NOT_STARTED"
|
||||
STALLED = "STALLED"
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset(
|
||||
{
|
||||
ExecutionTruthState.SUCCEEDED,
|
||||
ExecutionTruthState.FAILED,
|
||||
ExecutionTruthState.CANCELLED,
|
||||
ExecutionTruthState.NOT_STARTED,
|
||||
ExecutionTruthState.STALLED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTaskManifest:
|
||||
task_id: str
|
||||
executor: str
|
||||
source_branch: str
|
||||
source_oid: str
|
||||
target_version: str
|
||||
steps: Sequence[str]
|
||||
start_deadline_seconds: int = 300
|
||||
heartbeat_timeout_seconds: int = 180
|
||||
required_evidence: Sequence[str] = ()
|
||||
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
|
||||
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
manifest_version: str = "aios.external-task.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"task_id",
|
||||
"executor",
|
||||
"source_branch",
|
||||
"source_oid",
|
||||
"target_version",
|
||||
"manifest_version",
|
||||
"owner_notification_ref",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
steps = tuple(required(item, "step") for item in self.steps)
|
||||
if not steps:
|
||||
raise ValueError("at least one execution step is required")
|
||||
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
|
||||
raise ValueError("execution deadlines must be positive")
|
||||
object.__setattr__(self, "steps", steps)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_evidence",
|
||||
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"notification_events",
|
||||
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["steps"] = list(self.steps)
|
||||
value["required_evidence"] = list(self.required_evidence)
|
||||
value["notification_events"] = list(self.notification_events)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionTaskManifest":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTruthRecord:
|
||||
task_id: str
|
||||
manifest_json: str
|
||||
state: ExecutionTruthState
|
||||
executor_run_id: str | None = None
|
||||
executor_id: str | None = None
|
||||
declared_at: str = field(default_factory=utc_now)
|
||||
registered_at: str | None = None
|
||||
claimed_at: str | None = None
|
||||
started_at: str | None = None
|
||||
heartbeat_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
current_step: int = 0
|
||||
evidence: Mapping[str, str] = field(default_factory=dict)
|
||||
error: Mapping[str, Any] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
@property
|
||||
def manifest(self) -> ExecutionTaskManifest:
|
||||
return ExecutionTaskManifest.from_dict(json.loads(self.manifest_json))
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
**asdict(self),
|
||||
"state": self.state.value,
|
||||
"manifest": self.manifest.as_dict(),
|
||||
"evidence": dict(self.evidence),
|
||||
"error": dict(self.error),
|
||||
}
|
||||
|
||||
|
||||
class ExecutionTruthConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SQLiteExecutionTruthRegistry:
|
||||
"""Persistent registration and run-evidence store."""
|
||||
|
||||
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 execution_truth (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS execution_truth_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(task_id) REFERENCES execution_truth(task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_execution_truth_state
|
||||
ON execution_truth(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")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _manifest_json(manifest: ExecutionTaskManifest) -> str:
|
||||
return json.dumps(manifest.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _record_json(record: ExecutionTruthRecord) -> str:
|
||||
value = record.as_dict()
|
||||
value.pop("manifest", None)
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> ExecutionTruthRecord:
|
||||
value = json.loads(row["record_json"])
|
||||
value["state"] = ExecutionTruthState(value["state"])
|
||||
value["evidence"] = dict(value.get("evidence") or {})
|
||||
value["error"] = dict(value.get("error") or {})
|
||||
return ExecutionTruthRecord(**value)
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO execution_truth_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"truth_evt_{uuid.uuid4().hex}",
|
||||
task_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True, default=str),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
|
||||
manifest_json = self._manifest_json(manifest)
|
||||
record = ExecutionTruthRecord(
|
||||
task_id=manifest.task_id,
|
||||
manifest_json=manifest_json,
|
||||
state=ExecutionTruthState.DECLARED,
|
||||
)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(manifest.task_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
current = self._decode(existing)
|
||||
if current.manifest_json != manifest_json:
|
||||
previous_manifest = current.manifest.as_dict()
|
||||
replacement_manifest = manifest.as_dict()
|
||||
previous_oid = previous_manifest.pop("source_oid")
|
||||
replacement_oid = replacement_manifest.pop("source_oid")
|
||||
if (
|
||||
current.state != ExecutionTruthState.NOT_STARTED
|
||||
or previous_manifest != replacement_manifest
|
||||
):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
"task_id already declared with another manifest"
|
||||
)
|
||||
now = utc_now()
|
||||
updated = replace(
|
||||
current,
|
||||
manifest_json=manifest_json,
|
||||
revision=current.revision + 1,
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET manifest_json = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
manifest_json,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
"MANIFEST_SUPERSEDED",
|
||||
{
|
||||
"previous_source_oid": previous_oid,
|
||||
"source_oid": replacement_oid,
|
||||
"state": updated.state.value,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
connection.rollback()
|
||||
return current
|
||||
connection.execute(
|
||||
"INSERT INTO execution_truth VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
record.task_id,
|
||||
manifest_json,
|
||||
record.state.value,
|
||||
self._record_json(record),
|
||||
record.revision,
|
||||
record.declared_at,
|
||||
),
|
||||
)
|
||||
self._event(connection, record.task_id, "DECLARED", {"source_oid": manifest.source_oid})
|
||||
connection.commit()
|
||||
return record
|
||||
|
||||
def get(self, task_id: str) -> ExecutionTruthRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("execution task not declared")
|
||||
return self._decode(row)
|
||||
|
||||
def _transition(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
allowed: Sequence[ExecutionTruthState],
|
||||
target: ExecutionTruthState,
|
||||
executor_run_id: str | None = None,
|
||||
executor_id: str | None = None,
|
||||
current_step: int | None = None,
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("execution task not declared")
|
||||
current = self._decode(row)
|
||||
if current.state not in set(allowed):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
f"cannot transition {current.state.value} to {target.value}"
|
||||
)
|
||||
now = utc_now()
|
||||
updates: dict[str, Any] = {
|
||||
"state": target,
|
||||
"revision": current.revision + 1,
|
||||
}
|
||||
if executor_run_id is not None:
|
||||
if current.executor_run_id and current.executor_run_id != executor_run_id:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("executor_run_id mismatch")
|
||||
updates["executor_run_id"] = required(executor_run_id, "executor_run_id")
|
||||
if executor_id is not None:
|
||||
if current.executor_id and current.executor_id != executor_id:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("executor_id mismatch")
|
||||
updates["executor_id"] = required(executor_id, "executor_id")
|
||||
if target == ExecutionTruthState.REGISTERED:
|
||||
updates["registered_at"] = now
|
||||
if target == ExecutionTruthState.CLAIMED:
|
||||
updates["claimed_at"] = now
|
||||
updates["heartbeat_at"] = now
|
||||
if target == ExecutionTruthState.RUNNING:
|
||||
updates["started_at"] = current.started_at or now
|
||||
updates["heartbeat_at"] = now
|
||||
if current_step is not None:
|
||||
if current_step < current.current_step or current_step > len(current.manifest.steps):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("invalid task step progression")
|
||||
updates["current_step"] = current_step
|
||||
updates["heartbeat_at"] = now
|
||||
if evidence is not None:
|
||||
updates["evidence"] = {
|
||||
**dict(current.evidence),
|
||||
**{required(key, "evidence key"): required(value, "evidence hash") for key, value in evidence.items()},
|
||||
}
|
||||
if error is not None:
|
||||
updates["error"] = json.loads(json.dumps(dict(error), default=str))
|
||||
if target in TERMINAL_STATES:
|
||||
updates["completed_at"] = now
|
||||
updated = replace(current, **updates)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
updated.state.value,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
target.value,
|
||||
{
|
||||
"executor_run_id": updated.executor_run_id,
|
||||
"executor_id": updated.executor_id,
|
||||
"current_step": updated.current_step,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.DECLARED,),
|
||||
target=ExecutionTruthState.REGISTERED,
|
||||
executor_run_id=executor_run_id,
|
||||
)
|
||||
|
||||
def retry_not_started(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
"""Start a new audited attempt after the declaration deadline expired."""
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("execution task not declared")
|
||||
current = self._decode(row)
|
||||
if current.state != ExecutionTruthState.NOT_STARTED:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
f"cannot retry task from {current.state.value}"
|
||||
)
|
||||
now = utc_now()
|
||||
updated = replace(
|
||||
current,
|
||||
state=ExecutionTruthState.REGISTERED,
|
||||
executor_run_id=required(executor_run_id, "executor_run_id"),
|
||||
executor_id=None,
|
||||
registered_at=now,
|
||||
claimed_at=None,
|
||||
started_at=None,
|
||||
heartbeat_at=None,
|
||||
completed_at=None,
|
||||
current_step=0,
|
||||
evidence={},
|
||||
error={},
|
||||
revision=current.revision + 1,
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
updated.state.value,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
"RETRIED",
|
||||
{
|
||||
"previous_state": current.state.value,
|
||||
"previous_executor_run_id": current.executor_run_id,
|
||||
"executor_run_id": updated.executor_run_id,
|
||||
"previous_error": dict(current.error),
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def claim(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.REGISTERED,),
|
||||
target=ExecutionTruthState.CLAIMED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
|
||||
def start(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.CLAIMED,),
|
||||
target=ExecutionTruthState.RUNNING,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=1,
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
current_step: int,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.RUNNING,),
|
||||
target=ExecutionTruthState.RUNNING,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
current = self.get(task_id)
|
||||
missing = sorted(set(current.manifest.required_evidence) - set(evidence))
|
||||
if missing:
|
||||
raise ExecutionTruthConflict(f"missing required evidence: {missing}")
|
||||
if current.current_step != len(current.manifest.steps):
|
||||
raise ExecutionTruthConflict("all declared steps must complete before success")
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.RUNNING,),
|
||||
target=ExecutionTruthState.SUCCEEDED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.REGISTERED, ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING),
|
||||
target=ExecutionTruthState.FAILED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=evidence,
|
||||
error=error,
|
||||
)
|
||||
|
||||
def evaluate_deadlines(self, task_id: str, *, now: datetime | None = None) -> ExecutionTruthRecord:
|
||||
current = self.get(task_id)
|
||||
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
manifest = current.manifest
|
||||
if current.state in TERMINAL_STATES:
|
||||
return current
|
||||
declared = parse_time(current.declared_at)
|
||||
if current.state in {ExecutionTruthState.DECLARED, ExecutionTruthState.REGISTERED}:
|
||||
if current_time >= declared + timedelta(seconds=manifest.start_deadline_seconds):
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(current.state,),
|
||||
target=ExecutionTruthState.NOT_STARTED,
|
||||
error={"code": "start_deadline_exceeded"},
|
||||
)
|
||||
if current.state in {ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING}:
|
||||
heartbeat = parse_time(current.heartbeat_at or current.claimed_at or current.declared_at)
|
||||
if current_time >= heartbeat + timedelta(seconds=manifest.heartbeat_timeout_seconds):
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(current.state,),
|
||||
target=ExecutionTruthState.STALLED,
|
||||
error={"code": "heartbeat_timeout"},
|
||||
)
|
||||
return current
|
||||
|
||||
def gate(self, task_id: str) -> Mapping[str, Any]:
|
||||
record = self.evaluate_deadlines(task_id)
|
||||
passed = record.state == ExecutionTruthState.SUCCEEDED
|
||||
return {
|
||||
"task_id": record.task_id,
|
||||
"state": record.state.value,
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"executor_run_id": record.executor_run_id,
|
||||
"executor_id": record.executor_id,
|
||||
"current_step": record.current_step,
|
||||
"total_steps": len(record.manifest.steps),
|
||||
"evidence": dict(record.evidence),
|
||||
"error": dict(record.error),
|
||||
}
|
||||
|
||||
def events(self, task_id: str) -> list[Mapping[str, Any]]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT event_id, event_type, details_json, created_at FROM execution_truth_events WHERE task_id = ? ORDER BY created_at, event_id",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"event_type": row["event_type"],
|
||||
"details": json.loads(row["details_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""AIOS unified, role-aware web portal."""
|
||||
|
||||
from .application import UnifiedPortalApplication, build_application
|
||||
from .capabilities import ROLE_CAPABILITIES, allows, require, resolve
|
||||
|
||||
__all__ = [
|
||||
"ROLE_CAPABILITIES",
|
||||
"UnifiedPortalApplication",
|
||||
"allows",
|
||||
"build_application",
|
||||
"require",
|
||||
"resolve",
|
||||
]
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Unified, role-aware AIOS web application.
|
||||
|
||||
The portal wraps the production CandidateApplication instead of replacing its
|
||||
runtime/control-plane routes. Portal endpoints aggregate existing authoritative
|
||||
stores and require server-side capabilities before delegating any state change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_v2.app import (
|
||||
CandidateApplication,
|
||||
CandidateHTTPServer,
|
||||
build_application as build_candidate_application,
|
||||
)
|
||||
from aios_v2.control_plane import RequestContext, TaskState
|
||||
from aios_v2.gitops_status import collect_gitops_status
|
||||
from aios_v2.provenance import SQLiteProvenanceStore
|
||||
from aios_v2.provenance_grants import SQLiteGrantStore
|
||||
|
||||
from .capabilities import allows, require, resolve
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return {
|
||||
field.name: _json_value(getattr(value, field.name))
|
||||
for field in dataclasses.fields(value)
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _required(value: Any, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_count(items: list[Any]) -> int:
|
||||
return len(items)
|
||||
|
||||
|
||||
class AuthorizationCatalog:
|
||||
"""Tenant-filtered read/revoke access to existing Provenance stores."""
|
||||
|
||||
def __init__(self, store_file: str | Path) -> None:
|
||||
self.store_file = Path(store_file).resolve()
|
||||
|
||||
def _read_connection(self) -> sqlite3.Connection | None:
|
||||
if not self.store_file.exists():
|
||||
return None
|
||||
connection = sqlite3.connect(
|
||||
f"file:{self.store_file}?mode=ro",
|
||||
uri=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def snapshot(self, *, tenant_id: str) -> Mapping[str, Any]:
|
||||
connection = self._read_connection()
|
||||
if connection is None:
|
||||
return {
|
||||
"configured": False,
|
||||
"workflows": [],
|
||||
"grants": [],
|
||||
"summary": {"workflows": 0, "grants": 0, "active_grants": 0},
|
||||
}
|
||||
try:
|
||||
workflows: list[dict[str, Any]] = []
|
||||
grants: list[dict[str, Any]] = []
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "provenance_records" in tables:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.payload_json, r.status, r.signature_verified,
|
||||
r.created_at, r.updated_at,
|
||||
COUNT(u.authorization_id) AS usage_count
|
||||
FROM provenance_records r
|
||||
LEFT JOIN provenance_usage u
|
||||
ON u.provenance_id = r.provenance_id
|
||||
GROUP BY r.provenance_id
|
||||
ORDER BY r.updated_at DESC, r.provenance_id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload_json"])
|
||||
if payload.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
workflows.append(
|
||||
{
|
||||
**payload,
|
||||
"status": row["status"],
|
||||
"signature_verified": bool(row["signature_verified"]),
|
||||
"usage_count": int(row["usage_count"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
)
|
||||
if "provenance_grants" in tables:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT g.payload_json, g.status, g.signature_verified,
|
||||
g.created_at, g.updated_at,
|
||||
COUNT(u.authorization_id) AS usage_count
|
||||
FROM provenance_grants g
|
||||
LEFT JOIN provenance_grant_usage u
|
||||
ON u.grant_id = g.grant_id
|
||||
GROUP BY g.grant_id
|
||||
ORDER BY g.updated_at DESC, g.grant_id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload_json"])
|
||||
if payload.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
grants.append(
|
||||
{
|
||||
**payload,
|
||||
"status": row["status"],
|
||||
"signature_verified": bool(row["signature_verified"]),
|
||||
"usage_count": int(row["usage_count"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
)
|
||||
active = sum(
|
||||
1
|
||||
for item in grants
|
||||
if item.get("status") not in {"revoked", "expired"}
|
||||
)
|
||||
return {
|
||||
"configured": True,
|
||||
"workflows": workflows,
|
||||
"grants": grants,
|
||||
"summary": {
|
||||
"workflows": len(workflows),
|
||||
"grants": len(grants),
|
||||
"active_grants": active,
|
||||
"revoked_grants": sum(
|
||||
1 for item in grants if item.get("status") == "revoked"
|
||||
),
|
||||
},
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def revoke_workflow(
|
||||
self,
|
||||
*,
|
||||
provenance_id: str,
|
||||
tenant_id: str,
|
||||
principal_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, Any]:
|
||||
store = SQLiteProvenanceStore(self.store_file)
|
||||
try:
|
||||
record = store.get(provenance_id)
|
||||
if record.tenant_id != tenant_id:
|
||||
raise KeyError("workflow provenance not found")
|
||||
store.revoke(
|
||||
record.provenance_id,
|
||||
reason=reason,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
return {
|
||||
"provenance_id": record.provenance_id,
|
||||
"status": store.status(record.provenance_id).value,
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def revoke_grant(
|
||||
self,
|
||||
*,
|
||||
grant_id: str,
|
||||
tenant_id: str,
|
||||
principal_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, Any]:
|
||||
store = SQLiteGrantStore(self.store_file)
|
||||
try:
|
||||
grant = store.get(grant_id)
|
||||
if grant.tenant_id != tenant_id:
|
||||
raise KeyError("provenance grant not found")
|
||||
store.revoke(
|
||||
grant.grant_id,
|
||||
reason=reason,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
return {
|
||||
"grant_id": grant.grant_id,
|
||||
"status": store.status(grant.grant_id).value,
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class UnifiedPortalApplication:
|
||||
"""Role-aware portal facade over the existing production application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: CandidateApplication,
|
||||
*,
|
||||
provenance_store_file: str | Path,
|
||||
) -> None:
|
||||
self.base = base
|
||||
self.gateway = base.gateway
|
||||
self.orchestrator = base.orchestrator
|
||||
self.tracker = base.tracker
|
||||
self.hermes = base.hermes
|
||||
self.openclaw = base.openclaw
|
||||
self.authorization_catalog = AuthorizationCatalog(provenance_store_file)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.base.context(headers)
|
||||
|
||||
def _runtime(self, headers: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
status, payload = self.base.handle_json("GET", "/api/v2/runtime", headers, {})
|
||||
if int(status) != HTTPStatus.OK:
|
||||
raise RuntimeError("runtime status is unavailable")
|
||||
return payload
|
||||
|
||||
def _gitops(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
return {
|
||||
"tenant_id": context.tenant_id,
|
||||
"gitops": collect_gitops_status().as_dict(),
|
||||
}
|
||||
|
||||
def _tasks_view(self, context: RequestContext) -> list[Mapping[str, Any]]:
|
||||
service = self.orchestrator.control_plane
|
||||
tasks = service.list_tasks(context)
|
||||
dispatches = {
|
||||
item.task_id: item
|
||||
for item in self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
}
|
||||
evidence_by_task: dict[str, list[Any]] = {}
|
||||
for evidence in self.tracker.list(tenant_id=context.tenant_id):
|
||||
evidence_by_task.setdefault(evidence.task_id, []).append(evidence)
|
||||
audit_by_task: dict[str, list[Any]] = {}
|
||||
for event in service.list_audit_events(context):
|
||||
if event.resource_type == "task":
|
||||
audit_by_task.setdefault(event.resource_id, []).append(event)
|
||||
capabilities = set(resolve(context).capabilities)
|
||||
result: list[Mapping[str, Any]] = []
|
||||
for task in tasks:
|
||||
actions: list[str] = []
|
||||
if task.state == TaskState.CREATED and "task.approve" in capabilities:
|
||||
actions.append("approve")
|
||||
if task.state in {TaskState.CREATED, TaskState.APPROVED, TaskState.RUNNING} and "task.cancel" in capabilities:
|
||||
actions.append("cancel")
|
||||
if task.state == TaskState.APPROVED and "task.dispatch" in capabilities:
|
||||
actions.append("dispatch")
|
||||
result.append(
|
||||
{
|
||||
"task": _json_value(task),
|
||||
"dispatch": _json_value(dispatches.get(task.task_id)),
|
||||
"evidence": _json_value(evidence_by_task.get(task.task_id, [])),
|
||||
"audit": _json_value(audit_by_task.get(task.task_id, [])[-12:]),
|
||||
"available_actions": actions,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _overview(
|
||||
self,
|
||||
context: RequestContext,
|
||||
headers: Mapping[str, str],
|
||||
) -> Mapping[str, Any]:
|
||||
service = self.orchestrator.control_plane
|
||||
capabilities = resolve(context)
|
||||
sections: dict[str, Any] = {}
|
||||
tasks = service.list_tasks(context) if allows(context, "task.read") else []
|
||||
dispatches = (
|
||||
self.orchestrator.dispatch_repository.list(tenant_id=context.tenant_id)
|
||||
if allows(context, "dispatch.read")
|
||||
else []
|
||||
)
|
||||
evidence = (
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
if allows(context, "evidence.read")
|
||||
else []
|
||||
)
|
||||
audit = (
|
||||
service.list_audit_events(context)
|
||||
if allows(context, "audit.read")
|
||||
else []
|
||||
)
|
||||
if allows(context, "runtime.read"):
|
||||
sections["runtime"] = self._runtime(headers)
|
||||
if allows(context, "release.read"):
|
||||
sections["release"] = {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
|
||||
}
|
||||
if allows(context, "provenance.read"):
|
||||
sections["authorization"] = self.authorization_catalog.snapshot(
|
||||
tenant_id=context.tenant_id
|
||||
)["summary"]
|
||||
task_states: dict[str, int] = {}
|
||||
for task in tasks:
|
||||
task_states[task.state.value] = task_states.get(task.state.value, 0) + 1
|
||||
return {
|
||||
"identity": capabilities.as_dict(),
|
||||
"summary": {
|
||||
"tasks": _safe_count(tasks),
|
||||
"task_states": task_states,
|
||||
"dispatches": _safe_count(dispatches),
|
||||
"active_dispatches": sum(
|
||||
1
|
||||
for item in dispatches
|
||||
if item.state.value not in {"COMPLETED", "FAILED", "CANCELLED"}
|
||||
),
|
||||
"evidence": _safe_count(evidence),
|
||||
"audit_events": _safe_count(audit),
|
||||
},
|
||||
"sections": sections,
|
||||
"recent": {
|
||||
"tasks": _json_value(tasks[:8]),
|
||||
"dispatches": _json_value(dispatches[-8:]),
|
||||
"audit": _json_value(audit[-12:]),
|
||||
},
|
||||
}
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if not path.startswith("/api/v2/portal") and path != "/api/v2/me/capabilities":
|
||||
return self.base.handle_json(method, raw_path, headers, body)
|
||||
|
||||
context = self.context(headers)
|
||||
payload = _mapping(body or {})
|
||||
|
||||
if method == "GET" and path == "/api/v2/me/capabilities":
|
||||
require(context, "portal.access")
|
||||
return HTTPStatus.OK, resolve(context).as_dict()
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/overview":
|
||||
require(context, "dashboard.read")
|
||||
return HTTPStatus.OK, self._overview(context, headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/tasks":
|
||||
require(context, "task.read")
|
||||
return HTTPStatus.OK, {"items": self._tasks_view(context)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/authorizations":
|
||||
require(context, "provenance.read")
|
||||
return HTTPStatus.OK, self.authorization_catalog.snapshot(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/runtime":
|
||||
require(context, "runtime.read")
|
||||
return HTTPStatus.OK, {
|
||||
**self._runtime(headers),
|
||||
"dispatches": _json_value(
|
||||
self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
),
|
||||
"hermes_jobs": _json_value(
|
||||
self.hermes.list(tenant_id=context.tenant_id)
|
||||
),
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/evidence":
|
||||
require(context, "evidence.read")
|
||||
return HTTPStatus.OK, {
|
||||
"executions": _json_value(
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
)
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/audit":
|
||||
require(context, "audit.read")
|
||||
return HTTPStatus.OK, {
|
||||
"events": _json_value(
|
||||
self.orchestrator.control_plane.list_audit_events(context)
|
||||
)
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/release":
|
||||
require(context, "release.read")
|
||||
return HTTPStatus.OK, {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
|
||||
"operations": (
|
||||
["review_release", "request_tag", "request_rollback"]
|
||||
if allows(context, "release.operate")
|
||||
else []
|
||||
),
|
||||
"note": "Release operations remain GitOps approval requests; the portal does not mutate tags or production directly.",
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/tasks":
|
||||
require(context, "task.create")
|
||||
return self.base.handle_json("POST", "/api/v2/tasks", headers, payload)
|
||||
|
||||
task_action = re.fullmatch(
|
||||
r"/api/v2/portal/tasks/([^/]+)/(approve|cancel|dispatch)", path
|
||||
)
|
||||
if method == "POST" and task_action:
|
||||
action = task_action.group(2)
|
||||
require(context, f"task.{action}")
|
||||
task_id = urllib.parse.unquote(task_action.group(1))
|
||||
target = (
|
||||
f"/api/v2/tasks/{task_id}/dispatch"
|
||||
if action == "dispatch"
|
||||
else f"/api/v2/tasks/{task_id}/{action}"
|
||||
)
|
||||
return self.base.handle_json("POST", target, headers, payload)
|
||||
|
||||
workflow_revoke = re.fullmatch(
|
||||
r"/api/v2/portal/provenance/([^/]+)/revoke", path
|
||||
)
|
||||
if method == "POST" and workflow_revoke:
|
||||
require(context, "provenance.revoke")
|
||||
reason = _required(payload.get("reason"), "reason")
|
||||
result = self.authorization_catalog.revoke_workflow(
|
||||
provenance_id=urllib.parse.unquote(workflow_revoke.group(1)),
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
reason=reason,
|
||||
)
|
||||
return HTTPStatus.OK, {"workflow": result}
|
||||
|
||||
grant_revoke = re.fullmatch(r"/api/v2/portal/grants/([^/]+)/revoke", path)
|
||||
if method == "POST" and grant_revoke:
|
||||
require(context, "provenance.revoke")
|
||||
reason = _required(payload.get("reason"), "reason")
|
||||
result = self.authorization_catalog.revoke_grant(
|
||||
grant_id=urllib.parse.unquote(grant_revoke.group(1)),
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
reason=reason,
|
||||
)
|
||||
return HTTPStatus.OK, {"grant": result}
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "portal route not found",
|
||||
}
|
||||
|
||||
|
||||
def build_application() -> UnifiedPortalApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
provenance_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
str(state_dir / "run-provenance.sqlite3"),
|
||||
)
|
||||
)
|
||||
return UnifiedPortalApplication(
|
||||
build_candidate_application(),
|
||||
provenance_store_file=provenance_file,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||
server = CandidateHTTPServer((host, port), build_application())
|
||||
print(f"AIOS V2 unified portal listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Server-side role and capability resolution for the unified AIOS portal."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
|
||||
|
||||
ROLE_CAPABILITIES: Mapping[str, frozenset[str]] = {
|
||||
"platform_admin": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "tenant.manage",
|
||||
"agent.read", "agent.manage", "task.read", "task.create", "task.approve",
|
||||
"task.cancel", "task.dispatch", "policy.read", "audit.read", "evidence.read",
|
||||
"runtime.read", "runtime.operate", "dispatch.read", "provenance.read",
|
||||
"provenance.revoke", "external_task.read", "gitops.read", "release.read",
|
||||
"release.operate", "worktree.read", "system.admin",
|
||||
}),
|
||||
"tenant_admin": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "agent.manage",
|
||||
"task.read", "task.create", "task.approve", "task.cancel", "task.dispatch",
|
||||
"policy.read", "audit.read", "evidence.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "provenance.revoke", "external_task.read", "gitops.read",
|
||||
"release.read",
|
||||
}),
|
||||
"operator": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
|
||||
"task.create", "task.cancel", "task.dispatch", "runtime.read", "dispatch.read",
|
||||
"evidence.read", "provenance.read", "external_task.read", "gitops.read",
|
||||
"release.read",
|
||||
}),
|
||||
"approver": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "task.read", "task.approve",
|
||||
"task.cancel", "policy.read", "audit.read", "provenance.read", "evidence.read",
|
||||
"external_task.read", "release.read",
|
||||
}),
|
||||
"auditor": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
|
||||
"audit.read", "evidence.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "external_task.read", "gitops.read", "release.read",
|
||||
"worktree.read",
|
||||
}),
|
||||
"runtime": frozenset({
|
||||
"portal.access", "dashboard.read", "task.read", "runtime.read", "runtime.operate",
|
||||
"dispatch.read", "evidence.read", "provenance.read", "external_task.read",
|
||||
}),
|
||||
"release_manager": frozenset({
|
||||
"portal.access", "dashboard.read", "audit.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "external_task.read", "gitops.read", "release.read",
|
||||
"release.operate", "worktree.read",
|
||||
}),
|
||||
"viewer": frozenset({
|
||||
"portal.access", "dashboard.read", "task.read", "runtime.read",
|
||||
"external_task.read",
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
NAVIGATION: Sequence[Mapping[str, str]] = (
|
||||
{"id": "overview", "label": "总览", "capability": "dashboard.read"},
|
||||
{"id": "resources", "label": "租户与 Agent", "capability": "tenant.read"},
|
||||
{"id": "tasks", "label": "任务中心", "capability": "task.read"},
|
||||
{"id": "execution", "label": "执行中心", "capability": "external_task.read"},
|
||||
{"id": "authorization", "label": "授权中心", "capability": "provenance.read"},
|
||||
{"id": "runtime", "label": "运行与运维", "capability": "runtime.read"},
|
||||
{"id": "evidence", "label": "执行证据", "capability": "evidence.read"},
|
||||
{"id": "audit", "label": "审计中心", "capability": "audit.read"},
|
||||
{"id": "release", "label": "GitOps 与发布", "capability": "release.read"},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedCapabilities:
|
||||
tenant_id: str
|
||||
principal_id: str
|
||||
roles: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
navigation: tuple[Mapping[str, str], ...]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"principal_id": self.principal_id,
|
||||
"roles": list(self.roles),
|
||||
"capabilities": list(self.capabilities),
|
||||
"navigation": [dict(item) for item in self.navigation],
|
||||
}
|
||||
|
||||
|
||||
def resolve(context: RequestContext) -> ResolvedCapabilities:
|
||||
capabilities: set[str] = set()
|
||||
for role in context.roles:
|
||||
capabilities.update(ROLE_CAPABILITIES.get(role, ()))
|
||||
ordered = tuple(sorted(capabilities))
|
||||
navigation = tuple(item for item in NAVIGATION if item["capability"] in capabilities)
|
||||
return ResolvedCapabilities(
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
roles=tuple(sorted(context.roles)),
|
||||
capabilities=ordered,
|
||||
navigation=navigation,
|
||||
)
|
||||
|
||||
|
||||
def require(context: RequestContext, capability: str) -> None:
|
||||
if capability not in resolve(context).capabilities:
|
||||
raise Unauthorized(f"portal capability is required: {capability}")
|
||||
|
||||
|
||||
def allows(context: RequestContext, capability: str) -> bool:
|
||||
return capability in resolve(context).capabilities
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unified portal HTML export with V2R1C2 resource and execution centers.
|
||||
|
||||
The canonical dependency-free portal remains in ``aios_v2.console``. This
|
||||
module applies deterministic, fail-fast extensions at import time so the
|
||||
V2R1C1 production entrypoint remains unchanged while the V2R1C2 candidate gets
|
||||
resource and portable-executor views.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from aios_v2.console import CONSOLE_HTML as _BASE_PORTAL_HTML
|
||||
|
||||
|
||||
def _extend_portal(source: str) -> str:
|
||||
replacements = (
|
||||
(
|
||||
'<section id="overview" class="view active"></section>\n <section id="tasks" class="view"></section>\n <section id="authorization" class="view"></section>',
|
||||
'<section id="overview" class="view active"></section>\n <section id="resources" class="view"></section>\n <section id="tasks" class="view"></section>\n <section id="execution" class="view"></section>\n <section id="authorization" class="view"></section>',
|
||||
),
|
||||
(
|
||||
"const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心'",
|
||||
"const labels={overview:'系统总览',resources:'租户与 Agent',tasks:'任务中心',execution:'执行中心',authorization:'授权中心'",
|
||||
),
|
||||
(
|
||||
"if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();",
|
||||
"if(view==='overview')await overview();if(view==='resources')await resources();if(view==='tasks')await tasks();if(view==='execution')await execution();if(view==='authorization')await authorizations();",
|
||||
),
|
||||
(
|
||||
"async function tasks(){const d=await api('/api/v2/portal/tasks');",
|
||||
"""async function resources(){const d=await api('/api/v2/portal/resources');const t=d.tenant||{},agents=d.agents||[],ops=d.operations||{};const agentButton=ops.agent_create?'<button class=\"btn\" id=\"newAgent\">注册 Agent</button>':'';$('#resources').innerHTML=`<div class=\"cards\">${card('租户',t.tenant_id||'—',t.status||'')}${card('Agent',d.summary?.agents||0,`启用 ${d.summary?.enabled_agents||0}`)}${card('任务',d.summary?.tasks||0)}${card('Runtime 类型',(d.summary?.runtimes||[]).length,(d.summary?.runtimes||[]).join(', '))}</div><div class=\"grid\"><div class=\"panel\"><div class=\"panel-head\"><h2>当前租户</h2></div><div class=\"kv\"><div>Tenant ID</div><div>${esc(t.tenant_id||'—')}</div><div>名称</div><div>${esc(t.display_name||'—')}</div><div>状态</div><div>${pill(t.status||'unknown')}</div><div>创建时间</div><div>${esc(t.created_at||'—')}</div><div>更新时间</div><div>${esc(t.updated_at||'—')}</div></div></div><div class=\"panel\"><div class=\"panel-head\"><h2>Agent Registry</h2><div>${agentButton}</div></div>${table([['Agent',x=>esc(x.agent_id)],['名称',x=>esc(x.display_name)],['Runtime',x=>pill(x.runtime)],['能力',x=>(x.capabilities||[]).map(pill).join('')],['启用',x=>pill(x.enabled?'enabled':'disabled')],['任务数',x=>esc(x.task_count)]],agents)}</div></div>`;$('#newAgent')?.addEventListener('click',()=>{const agent_id=prompt('Agent ID');const display_name=prompt('显示名称',agent_id||'');const runtime=prompt('Runtime Provider','openclaw');const capabilities=prompt('能力,逗号分隔','');if(!agent_id||!display_name||!runtime)return;api('/api/v2/portal/agents',{method:'POST',body:JSON.stringify({agent_id,display_name,runtime,capabilities:String(capabilities||'').split(',').map(x=>x.trim()).filter(Boolean),metadata:{source:'unified-portal'}})}).then(resources).catch(err)})}
|
||||
function executionStage(x){const s=x.stage||{},status=x.truth||{},record=status.record||{},gate=status.gate||{},d=x.dependency_gate||{},notifications=gate.notifications||[],evidence=record.evidence||{};const stateName=record.state||'WAITING_DEPENDENCY';const deps=(s.depends_on||[]).join(', ')||'根阶段';return `<div class=\"panel\" style=\"background:#fbfcfe\"><div class=\"panel-head\"><h2>${esc(s.stage_id)} · ${esc(s.role)}</h2><div>${pill(stateName)} ${pill(s.executor_type)}</div></div><div class=\"chain\"><div class=\"chain-item\"><b>Provider</b><div>${esc(s.executor_type)}</div><small>${esc(deps)}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Run</b><div>${esc(record.executor_run_id||'未注册')}</div><small>${esc(record.executor_id||'')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Progress</b><div>${esc(record.current_step||0)}/${esc((record.manifest?.steps||s.steps||[]).length)}</div><small>${esc(record.heartbeat_at||'无心跳')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Evidence</b><div>${Object.keys(evidence).length} 项</div><small>${Object.keys(evidence).slice(0,2).map(esc).join(', ')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Notification</b><div>${notifications.length} 条</div><small>${notifications.map(n=>n.status).join(', ')||'无'}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Gate</b><div>${pill(gate.passed?'PASS':'BLOCKED')}</div><small>${d.passed?'依赖满足':'等待依赖'}</small></div></div>${Object.keys(record.error||{}).length?json(record.error):''}</div>`}
|
||||
async function execution(){const d=await api('/api/v2/portal/execution');const providers=d.providers||[],plans=d.plans||[];$('#execution').innerHTML=`<div class=\"cards\">${card('Provider',providers.length)}${card('Execution Plan',plans.length)}${card('协议',d.protocol_version||'—')}${card('Plan Schema',d.plan_schema_version||'—')}</div><div class=\"panel\"><div class=\"panel-head\"><h2>Executor Provider Catalog</h2></div>${table([['Provider',x=>esc(x.executor_type)],['Adapter',x=>esc(x.adapter_version)],['协议',x=>esc(x.protocol_version)],['Trust Roles',x=>(x.trust_roles||[]).map(pill).join('')],['Capabilities',x=>(x.capabilities||[]).map(pill).join('')]],providers)}</div>${plans.map(p=>`<div class=\"panel\"><div class=\"panel-head\"><h2>${esc(p.plan_id)} · ${esc(p.target_version)}</h2><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div><div class=\"kv\"><div>Source Branch</div><div>${esc(p.source_branch)}</div><div>Source OID</div><div>${esc(p.source_oid)}</div><div>Release Gate</div><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div></div>${(p.snapshot?.stages||[]).map(executionStage).join('')}`).join('')||'<div class=\"empty\">尚未声明执行计划</div>'}`}
|
||||
async function tasks(){const d=await api('/api/v2/portal/tasks');""",
|
||||
),
|
||||
)
|
||||
result = source
|
||||
for old, new in replacements:
|
||||
if old not in result:
|
||||
raise RuntimeError(f"unified portal extension anchor not found: {old[:100]}")
|
||||
result = result.replace(old, new, 1)
|
||||
return result
|
||||
|
||||
|
||||
PORTAL_HTML = _extend_portal(_BASE_PORTAL_HTML)
|
||||
|
||||
if "\n+function executionStage" in PORTAL_HTML or "\n+async function execution" in PORTAL_HTML:
|
||||
raise RuntimeError("execution center contains an invalid diff marker")
|
||||
|
||||
__all__ = ["PORTAL_HTML"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""HTTP facade for portable multi-provider execution plans."""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
from aios_v2.execution_plan import ExecutionPlanCoordinator
|
||||
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
|
||||
from .capabilities import require
|
||||
|
||||
|
||||
class ExecutionPlanAPI:
|
||||
"""Portal read model and provider-authenticated plan discovery API."""
|
||||
|
||||
INTERNAL_PREFIX = "/api/v2/internal/execution-plans"
|
||||
PORTAL_PATH = "/api/v2/portal/execution"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExecutionPlanCoordinator,
|
||||
*,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.providers = providers
|
||||
|
||||
@classmethod
|
||||
def is_route(cls, raw_path: str) -> bool:
|
||||
path = urllib.parse.urlparse(raw_path).path.rstrip("/") or "/"
|
||||
return path == cls.PORTAL_PATH or path.startswith(cls.INTERNAL_PREFIX)
|
||||
|
||||
def _require_provider(
|
||||
self,
|
||||
context: RequestContext,
|
||||
executor_type: str,
|
||||
) -> str:
|
||||
normalized = normalize_executor(executor_type)
|
||||
if not self.providers.trusted(
|
||||
executor_type=normalized,
|
||||
roles=tuple(context.roles),
|
||||
platform_admin="platform_admin" in context.roles,
|
||||
):
|
||||
raise Unauthorized(
|
||||
f"trusted role for executor Provider is required: {normalized}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _portal_snapshot(self) -> Mapping[str, Any]:
|
||||
plans = []
|
||||
for plan in self.coordinator.plan_store.list():
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": plan.plan_id,
|
||||
"target_version": plan.target_version,
|
||||
"source_branch": plan.source_branch,
|
||||
"source_oid": plan.source_oid,
|
||||
"snapshot": self.coordinator.snapshot(plan.plan_id),
|
||||
"gate": self.coordinator.gate(plan.plan_id),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"protocol_version": "aios.executor.v1",
|
||||
"plan_schema_version": "aios.execution-plan.v2",
|
||||
"providers": [
|
||||
item.as_dict() for item in self.providers.list_descriptors()
|
||||
],
|
||||
"plans": plans,
|
||||
}
|
||||
|
||||
def handle(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
context: RequestContext,
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
del body
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if method == "GET" and path == self.PORTAL_PATH:
|
||||
require(context, "external_task.read")
|
||||
return HTTPStatus.OK, self._portal_snapshot()
|
||||
|
||||
if not path.startswith(f"{self.INTERNAL_PREFIX}/"):
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
|
||||
remainder = urllib.parse.unquote(path[len(self.INTERNAL_PREFIX) + 1 :])
|
||||
parts = tuple(item for item in remainder.split("/") if item)
|
||||
if len(parts) != 2:
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
plan_id, action = parts
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
executor_type = self._require_provider(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
|
||||
if method == "GET" and action == "discover":
|
||||
items = self.coordinator.discover_ready(
|
||||
plan_id=plan_id,
|
||||
executor_type=executor_type,
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"plan_id": plan_id,
|
||||
"executor_type": executor_type,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
if method == "GET" and action == "snapshot":
|
||||
return HTTPStatus.OK, self.coordinator.snapshot(plan_id)
|
||||
|
||||
if method == "GET" and action == "gate":
|
||||
return HTTPStatus.OK, self.coordinator.gate(plan_id)
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Protected HTTP facade for portable executor truth and notification receipts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
from aios_v2.external_task_control import ExternalTaskCoordinator
|
||||
from aios_v2.external_task_query import list_records
|
||||
from aios_v2.task_execution_registry import ExecutionTruthState
|
||||
|
||||
from .capabilities import require
|
||||
|
||||
|
||||
def _body(value: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _required(value: Any, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _mapping_strings(value: Any, label: str) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
return {
|
||||
_required(key, f"{label} key"): _required(item, f"{label}[{key}]")
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
class ExternalTaskAPI:
|
||||
"""Fail-closed external task API shared by every registered Provider."""
|
||||
|
||||
INTERNAL_PREFIX = "/api/v2/internal/external-tasks"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExternalTaskCoordinator,
|
||||
*,
|
||||
store_file: str | Path,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.store_file = Path(store_file).resolve()
|
||||
self.providers = providers
|
||||
|
||||
@staticmethod
|
||||
def is_route(path: str) -> bool:
|
||||
normalized = path.rstrip("/") or "/"
|
||||
return normalized.startswith(ExternalTaskAPI.INTERNAL_PREFIX) or normalized == "/api/v2/portal/external-tasks"
|
||||
|
||||
def _require_executor(self, context: RequestContext, executor_type: str) -> str:
|
||||
normalized = normalize_executor(executor_type)
|
||||
if not self.providers.trusted(
|
||||
executor_type=normalized,
|
||||
roles=tuple(context.roles),
|
||||
platform_admin="platform_admin" in context.roles,
|
||||
):
|
||||
raise Unauthorized(
|
||||
f"trusted role for executor Provider is required: {normalized}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _task_executor(self, task_id: str) -> str:
|
||||
return normalize_executor(
|
||||
self.coordinator.registry.get(task_id).manifest.executor
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _executor_identity(
|
||||
context: RequestContext,
|
||||
payload: Mapping[str, Any],
|
||||
) -> str:
|
||||
executor_id = _required(payload.get("executor_id"), "executor_id")
|
||||
if executor_id != context.principal_id and "platform_admin" not in context.roles:
|
||||
raise Unauthorized("executor_id must match trusted principal")
|
||||
return executor_id
|
||||
|
||||
def declare_directory(self, directory: str | Path) -> list[Mapping[str, Any]]:
|
||||
root = Path(directory).resolve()
|
||||
if not root.exists():
|
||||
return []
|
||||
declared: list[Mapping[str, Any]] = []
|
||||
for manifest in sorted(root.glob("*.json")):
|
||||
record = self.coordinator.declare_file(manifest)
|
||||
self.providers.validate_manifest(record.manifest)
|
||||
declared.append(
|
||||
{
|
||||
"manifest": str(manifest),
|
||||
"task_id": record.task_id,
|
||||
"executor_type": record.manifest.executor,
|
||||
"state": record.state.value,
|
||||
}
|
||||
)
|
||||
return declared
|
||||
|
||||
def handle(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
context: RequestContext,
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
payload = _body(body or {})
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/external-tasks":
|
||||
require(context, "external_task.read")
|
||||
records = list_records(self.store_file)
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(records),
|
||||
"items": [self.coordinator.status(item.task_id) for item in records],
|
||||
}
|
||||
|
||||
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/discover":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
states = tuple(query.get("state", ()))
|
||||
executor = self._require_executor(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
records = list_records(
|
||||
self.store_file,
|
||||
executor=executor,
|
||||
states=states,
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(records),
|
||||
"executor_type": executor,
|
||||
"items": [item.as_dict() for item in records],
|
||||
}
|
||||
|
||||
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/notifications":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
executor = self._require_executor(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
task_id = query.get("task_id", [None])[0]
|
||||
status = query.get("status", [None])[0]
|
||||
items = self.coordinator.notifications.list(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
)
|
||||
filtered = [
|
||||
item
|
||||
for item in items
|
||||
if self._task_executor(item.task_id) == executor
|
||||
]
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(filtered),
|
||||
"executor_type": executor,
|
||||
"items": [item.as_dict() for item in filtered],
|
||||
}
|
||||
|
||||
notification_ack = self._match(
|
||||
path,
|
||||
f"{self.INTERNAL_PREFIX}/notifications/",
|
||||
"/ack",
|
||||
)
|
||||
if method == "POST" and notification_ack:
|
||||
notification = self.coordinator.notifications.get(notification_ack)
|
||||
executor = self._task_executor(notification.task_id)
|
||||
self._require_executor(context, executor)
|
||||
self._executor_identity(context, payload)
|
||||
record = self.coordinator.notifications.acknowledge(
|
||||
notification_ack,
|
||||
receipt_sha256=_required(
|
||||
payload.get("receipt_sha256"),
|
||||
"receipt_sha256",
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"notification": record.as_dict()}
|
||||
|
||||
truth_task = self._match(path, f"{self.INTERNAL_PREFIX}/", "/truth")
|
||||
if method == "GET" and truth_task:
|
||||
self._require_executor(context, self._task_executor(truth_task))
|
||||
return HTTPStatus.OK, self.coordinator.status(truth_task)
|
||||
|
||||
for action in ("register", "retry", "claim", "start", "heartbeat", "succeed", "fail"):
|
||||
task_id = self._match(path, f"{self.INTERNAL_PREFIX}/", f"/{action}")
|
||||
if method != "POST" or not task_id:
|
||||
continue
|
||||
executor_type = self._task_executor(task_id)
|
||||
self._require_executor(context, executor_type)
|
||||
run_id = _required(payload.get("executor_run_id"), "executor_run_id")
|
||||
if action == "register":
|
||||
return HTTPStatus.OK, {
|
||||
"record": self.coordinator.register(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
).as_dict()
|
||||
}
|
||||
if action == "retry":
|
||||
return HTTPStatus.OK, {
|
||||
"record": self.coordinator.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
).as_dict()
|
||||
}
|
||||
executor_id = self._executor_identity(context, payload)
|
||||
if action == "claim":
|
||||
record = self.coordinator.claim(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
elif action == "start":
|
||||
record = self.coordinator.start(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
elif action == "heartbeat":
|
||||
record = self.coordinator.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=int(payload.get("current_step")),
|
||||
)
|
||||
elif action == "succeed":
|
||||
record = self.coordinator.succeed(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=_mapping_strings(payload.get("evidence"), "evidence"),
|
||||
)
|
||||
else:
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ValueError("error must be an object")
|
||||
evidence_value = payload.get("evidence")
|
||||
evidence = (
|
||||
_mapping_strings(evidence_value, "evidence")
|
||||
if evidence_value is not None
|
||||
else None
|
||||
)
|
||||
record = self.coordinator.fail(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
error=dict(error),
|
||||
evidence=evidence,
|
||||
)
|
||||
return HTTPStatus.OK, {"record": record.as_dict()}
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "external task route not found",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _match(path: str, prefix: str, suffix: str) -> str | None:
|
||||
if not path.startswith(prefix) or not path.endswith(suffix):
|
||||
return None
|
||||
middle = path[len(prefix) : len(path) - len(suffix)]
|
||||
if not middle or "/" in middle:
|
||||
return None
|
||||
return urllib.parse.unquote(middle)
|
||||
|
||||
|
||||
DISCOVERABLE_STATES: Sequence[str] = (
|
||||
ExecutionTruthState.DECLARED.value,
|
||||
ExecutionTruthState.REGISTERED.value,
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""V2R1C2 unified portal and portable executor release entrypoint.
|
||||
|
||||
The V2R1C1 production entrypoint remains unchanged. This candidate entrypoint
|
||||
uses an isolated state directory, loads a versioned executor catalog, declares
|
||||
portable multi-provider execution plans and exposes both portal read models and
|
||||
provider-authenticated lifecycle APIs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import replace
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import aios_v2.app as candidate_app_module
|
||||
from aios_v2.app import (
|
||||
CandidateApplication,
|
||||
CandidateHTTPServer,
|
||||
build_application as build_candidate_application,
|
||||
)
|
||||
from aios_v2.control_plane import RequestContext
|
||||
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
|
||||
from aios_v2.executor_catalog import FileExecutorCatalogStore
|
||||
from aios_v2.official_executor_providers import official_provider_factories
|
||||
|
||||
from .application import UnifiedPortalApplication, _json_value, _mapping
|
||||
from .capabilities import allows, require
|
||||
from .console import PORTAL_HTML
|
||||
from .execution_plan_api import ExecutionPlanAPI
|
||||
from .external_task_api import ExternalTaskAPI
|
||||
|
||||
|
||||
_READ_BRIDGE_ROLES = frozenset({"approver", "runtime", "viewer", "release_manager"})
|
||||
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def _resolve_environment_value(value: Any, *, label: str) -> Any:
|
||||
if not isinstance(value, str) or not value.startswith("ENV:"):
|
||||
return value
|
||||
variable = value[4:].strip()
|
||||
if not variable:
|
||||
raise ValueError(f"{label} environment variable name is empty")
|
||||
resolved = str(os.getenv(variable, "")).strip()
|
||||
if not resolved:
|
||||
raise RuntimeError(f"required environment variable is not set: {variable}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_execution_plan(path: str | Path) -> ExecutionPlanManifest:
|
||||
source = Path(path).resolve()
|
||||
value = json.loads(source.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"execution plan must contain a JSON object: {source}")
|
||||
payload = dict(value)
|
||||
for label in ("source_branch", "source_oid", "target_version"):
|
||||
payload[label] = _resolve_environment_value(payload.get(label), label=label)
|
||||
source_oid = str(payload.get("source_oid") or "").strip().lower()
|
||||
if not _GIT_OID.fullmatch(source_oid):
|
||||
raise ValueError(
|
||||
"execution plan source_oid must resolve to an exact 40-character Git OID"
|
||||
)
|
||||
payload["source_oid"] = source_oid
|
||||
return ExecutionPlanManifest.from_dict(payload)
|
||||
|
||||
|
||||
def _path_list(value: str) -> tuple[Path, ...]:
|
||||
return tuple(
|
||||
Path(item).expanduser().resolve()
|
||||
for item in value.split(os.pathsep)
|
||||
if item.strip()
|
||||
)
|
||||
|
||||
|
||||
class V2R1C2PortalApplication(UnifiedPortalApplication):
|
||||
"""Unified portal candidate with provider-neutral execution governance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: CandidateApplication,
|
||||
*,
|
||||
provenance_store_file: str | Path,
|
||||
external_task_api: ExternalTaskAPI,
|
||||
execution_plan_api: ExecutionPlanAPI,
|
||||
plan_coordinator: ExecutionPlanCoordinator,
|
||||
catalog_snapshot: Mapping[str, Any],
|
||||
declared_plans: Sequence[str],
|
||||
) -> None:
|
||||
super().__init__(base, provenance_store_file=provenance_store_file)
|
||||
self.external_task_api = external_task_api
|
||||
self.execution_plan_api = execution_plan_api
|
||||
self.plan_coordinator = plan_coordinator
|
||||
self.catalog_snapshot = dict(catalog_snapshot)
|
||||
self.declared_plans = tuple(declared_plans)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
original = self.base.context(headers)
|
||||
if original.roles.intersection(_READ_BRIDGE_ROLES):
|
||||
return replace(original, roles=frozenset((*original.roles, "auditor")))
|
||||
return original
|
||||
|
||||
def _resource_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
service = self.orchestrator.control_plane
|
||||
tenant = service.get_tenant(context)
|
||||
agents = service.list_agents(context) if allows(context, "agent.read") else []
|
||||
tasks = service.list_tasks(context) if allows(context, "task.read") else []
|
||||
tasks_by_agent: dict[str, int] = {}
|
||||
for task in tasks:
|
||||
tasks_by_agent[task.agent_id] = tasks_by_agent.get(task.agent_id, 0) + 1
|
||||
return {
|
||||
"tenant": _json_value(tenant),
|
||||
"agents": [
|
||||
{
|
||||
**_json_value(agent),
|
||||
"task_count": tasks_by_agent.get(agent.agent_id, 0),
|
||||
}
|
||||
for agent in agents
|
||||
],
|
||||
"summary": {
|
||||
"agents": len(agents),
|
||||
"enabled_agents": sum(1 for agent in agents if agent.enabled),
|
||||
"tasks": len(tasks),
|
||||
"runtimes": sorted({agent.runtime for agent in agents}),
|
||||
},
|
||||
"operations": {
|
||||
"tenant_create": allows(context, "tenant.manage"),
|
||||
"agent_create": allows(context, "agent.manage"),
|
||||
},
|
||||
}
|
||||
|
||||
def _runtime_snapshot(
|
||||
self,
|
||||
context: RequestContext,
|
||||
headers: Mapping[str, str],
|
||||
) -> Mapping[str, Any]:
|
||||
return {
|
||||
**self._runtime(headers),
|
||||
"dispatches": _json_value(
|
||||
self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
),
|
||||
"hermes_jobs": _json_value(
|
||||
self.hermes.list_jobs(tenant_id=context.tenant_id)
|
||||
),
|
||||
"contract": {
|
||||
"openclaw": os.getenv(
|
||||
"AIOS_OPENCLAW_CONTRACT_VERSION",
|
||||
"aios.openclaw.command.v2",
|
||||
),
|
||||
"signature_algorithm": os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNATURE_ALGORITHM",
|
||||
"Ed25519",
|
||||
),
|
||||
"plugin_version": os.getenv(
|
||||
"AIOS_OPENCLAW_PLUGIN_VERSION",
|
||||
"2.1.0-rc.4",
|
||||
),
|
||||
"key_id": os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID",
|
||||
"openclaw-v2r1-candidate-ed25519",
|
||||
),
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"execution_plan": "aios.execution-plan.v2",
|
||||
},
|
||||
}
|
||||
|
||||
def _release_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
plans = []
|
||||
for plan_id in self.declared_plans:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"gate": self.plan_coordinator.gate(plan_id),
|
||||
}
|
||||
)
|
||||
aggregate_passed = bool(plans) and all(
|
||||
item["gate"]["passed"] for item in plans
|
||||
)
|
||||
return {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C2-CANDIDATE"),
|
||||
"target_version": os.getenv("AIOS_TARGET_VERSION", "V2R1C2"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "candidate-not-tagged"),
|
||||
"source_oid": os.getenv("AIOS_RELEASE_SOURCE_OID", "missing"),
|
||||
"tag": os.getenv("AIOS_RELEASE_TAG", "NOT_CREATED"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"previous_version": os.getenv("AIOS_PREVIOUS_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "CANDIDATE_VALIDATION"),
|
||||
"evidence_manifest_sha256": os.getenv(
|
||||
"AIOS_RELEASE_EVIDENCE_SHA256",
|
||||
"pending",
|
||||
),
|
||||
"executor_catalog": {
|
||||
"path": self.catalog_snapshot.get("path"),
|
||||
"sha256": self.catalog_snapshot.get("catalog_sha256"),
|
||||
"protocol_version": "aios.executor.v1",
|
||||
},
|
||||
"execution_plans": plans,
|
||||
"execution_gate": {
|
||||
"passed": aggregate_passed,
|
||||
"release_blocking": not aggregate_passed,
|
||||
},
|
||||
"operations": (
|
||||
["review_release", "request_tag", "request_rollback"]
|
||||
if allows(context, "release.operate")
|
||||
else []
|
||||
),
|
||||
"note": (
|
||||
"Release operations remain GitOps approval requests. The portal "
|
||||
"never mutates tags, main, or production directly. Tag approval "
|
||||
"must remain blocked until every required execution-plan gate passes."
|
||||
),
|
||||
}
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if self.execution_plan_api.is_route(raw_path):
|
||||
context = (
|
||||
self.context(headers)
|
||||
if path == ExecutionPlanAPI.PORTAL_PATH
|
||||
else self.base.context(headers)
|
||||
)
|
||||
return self.execution_plan_api.handle(
|
||||
method,
|
||||
raw_path,
|
||||
context,
|
||||
body,
|
||||
)
|
||||
|
||||
if self.external_task_api.is_route(path):
|
||||
context = (
|
||||
self.context(headers)
|
||||
if path == "/api/v2/portal/external-tasks"
|
||||
else self.base.context(headers)
|
||||
)
|
||||
return self.external_task_api.handle(
|
||||
method,
|
||||
raw_path,
|
||||
context,
|
||||
body,
|
||||
)
|
||||
|
||||
context: RequestContext | None = None
|
||||
if path in {
|
||||
"/api/v2/portal/resources",
|
||||
"/api/v2/portal/runtime",
|
||||
"/api/v2/portal/release",
|
||||
"/api/v2/portal/tenants",
|
||||
"/api/v2/portal/agents",
|
||||
}:
|
||||
context = self.context(headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/resources":
|
||||
assert context is not None
|
||||
require(context, "tenant.read")
|
||||
return HTTPStatus.OK, self._resource_snapshot(context)
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/tenants":
|
||||
assert context is not None
|
||||
require(context, "tenant.manage")
|
||||
return self.base.handle_json(
|
||||
"POST",
|
||||
"/api/v2/tenants",
|
||||
headers,
|
||||
_mapping(body),
|
||||
)
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/agents":
|
||||
assert context is not None
|
||||
require(context, "agent.manage")
|
||||
return self.base.handle_json(
|
||||
"POST",
|
||||
"/api/v2/agents",
|
||||
headers,
|
||||
_mapping(body),
|
||||
)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/runtime":
|
||||
assert context is not None
|
||||
require(context, "runtime.read")
|
||||
return HTTPStatus.OK, self._runtime_snapshot(context, headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/release":
|
||||
assert context is not None
|
||||
require(context, "release.read")
|
||||
return HTTPStatus.OK, self._release_snapshot(context)
|
||||
|
||||
return super().handle_json(method, raw_path, headers, body)
|
||||
|
||||
|
||||
def build_application() -> V2R1C2PortalApplication:
|
||||
state_dir = Path(
|
||||
os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2r1c2-portal")
|
||||
).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
provenance_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
str(state_dir / "run-provenance.sqlite3"),
|
||||
)
|
||||
).resolve()
|
||||
truth_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_EXTERNAL_TASK_STORE_FILE",
|
||||
str(state_dir / "external-task-truth.sqlite3"),
|
||||
)
|
||||
).resolve()
|
||||
catalog_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_EXECUTOR_CATALOG_FILE",
|
||||
str(state_dir / "executor-catalog.json"),
|
||||
)
|
||||
).resolve()
|
||||
|
||||
catalog_store = FileExecutorCatalogStore(catalog_file)
|
||||
catalog_snapshot = catalog_store.initialize_defaults()
|
||||
providers = catalog_store.registry(
|
||||
provider_factories=official_provider_factories()
|
||||
)
|
||||
plan_coordinator = ExecutionPlanCoordinator.from_file(
|
||||
truth_file,
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
declared_plans: list[str] = []
|
||||
plan_files = _path_list(os.getenv("AIOS_EXECUTION_PLAN_FILES", ""))
|
||||
for plan_file in plan_files:
|
||||
plan = _load_execution_plan(plan_file)
|
||||
plan_coordinator.declare(plan)
|
||||
declared_plans.append(plan.plan_id)
|
||||
|
||||
base: CandidateApplication = build_candidate_application()
|
||||
external_task_api = ExternalTaskAPI(
|
||||
plan_coordinator.task_coordinator,
|
||||
store_file=truth_file,
|
||||
providers=providers,
|
||||
)
|
||||
execution_plan_api = ExecutionPlanAPI(
|
||||
plan_coordinator,
|
||||
providers=providers,
|
||||
)
|
||||
return V2R1C2PortalApplication(
|
||||
base,
|
||||
provenance_store_file=provenance_file,
|
||||
external_task_api=external_task_api,
|
||||
execution_plan_api=execution_plan_api,
|
||||
plan_coordinator=plan_coordinator,
|
||||
catalog_snapshot=catalog_snapshot.as_dict(),
|
||||
declared_plans=declared_plans,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
candidate_app_module.CONSOLE_HTML = PORTAL_HTML
|
||||
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_PORT", "18921"))
|
||||
server = CandidateHTTPServer((host, port), build_application())
|
||||
print(f"AIOS V2R1C2 unified portal listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,7 +9,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
RUN groupadd --system --gid 12001 aios \
|
||||
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2 \
|
||||
&& python3 -m pip install --no-cache-dir 'cryptography>=42'
|
||||
|
||||
WORKDIR /opt/aios/app
|
||||
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||
|
||||
+35
-11
@@ -1,21 +1,45 @@
|
||||
# AIOS V2 candidate runtime configuration.
|
||||
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||
# AIOS V2R1 Candidate 环境模板
|
||||
# 正式环境禁止保存明文 Secret。
|
||||
# Provenance/OpenClaw 私钥只用于签发端,不进入应用容器或 Git。
|
||||
|
||||
# Runtime
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18920
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||
|
||||
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||
# OpenClaw Ed25519 Command v2
|
||||
AIOS_OPENCLAW_URL=
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=/run/credentials/aios-v2.service/openclaw-command-private-key
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
|
||||
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||
# Parent Workflow Provenance
|
||||
AIOS_REQUIRE_RUN_PROVENANCE=true
|
||||
AIOS_PROVENANCE_PUBLIC_KEY_FILE=/etc/aios-v2/provenance/public-key.pem
|
||||
AIOS_PROVENANCE_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2/run-provenance.sqlite3
|
||||
AIOS_PROVENANCE_RECEIPT_DIR=/var/lib/aios-v2/provenance-receipts
|
||||
|
||||
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||
AIOS_TRUST_PROXY_HEADERS=true
|
||||
# Least-privilege child Grants: production requires all seven IDs.
|
||||
AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID=
|
||||
AIOS_PROVENANCE_GRANT_GIT_ID=
|
||||
AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID=
|
||||
AIOS_PROVENANCE_GRANT_RUNTIME_ID=
|
||||
AIOS_PROVENANCE_GRANT_EVIDENCE_ID=
|
||||
AIOS_PROVENANCE_GRANT_ROLLBACK_ID=
|
||||
AIOS_PROVENANCE_GRANT_AUDIT_ID=
|
||||
|
||||
# Candidate deployment metadata exposed through health tooling.
|
||||
AIOS_RELEASE_CHANNEL=candidate
|
||||
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||
# Per-operation context. Deployment/rollback wrappers populate these from the
|
||||
# approved task execution context; do not hard-code shared production values.
|
||||
AIOS_PROVENANCE_ID=
|
||||
AIOS_RUN_ID=
|
||||
AIOS_TENANT_ID=
|
||||
AIOS_TASK_ID=
|
||||
AIOS_AGENT_ID=
|
||||
AIOS_PRINCIPAL_ID=
|
||||
|
||||
# 禁止:
|
||||
# AIOS_PROVENANCE_PRIVATE_KEY_FILE
|
||||
# AIOS_OPENCLAW_TOKEN
|
||||
# API_KEY=value
|
||||
# PASSWORD=value
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
OPERATION_PATH="${1:-}"
|
||||
OPERATION_NAME="${2:-}"
|
||||
REQUEST_FILE="${3:-}"
|
||||
|
||||
fail() {
|
||||
echo "PROVENANCE_DENIED: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -n "$OPERATION_PATH" ]] || fail "usage: $0 <deployment|rollback|audit|code_change|runtime_execute|evidence_write> <operation> <request-json-file>"
|
||||
[[ -n "$OPERATION_NAME" ]] || fail "operation name is required"
|
||||
[[ -n "$REQUEST_FILE" && -f "$REQUEST_FILE" ]] || fail "request JSON file is required"
|
||||
|
||||
: "${AIOS_PROVENANCE_PUBLIC_KEY_FILE:?missing AIOS_PROVENANCE_PUBLIC_KEY_FILE}"
|
||||
: "${AIOS_PROVENANCE_KEY_ID:?missing AIOS_PROVENANCE_KEY_ID}"
|
||||
: "${AIOS_PROVENANCE_STORE_FILE:?missing AIOS_PROVENANCE_STORE_FILE}"
|
||||
: "${AIOS_PROVENANCE_RECEIPT_DIR:?missing AIOS_PROVENANCE_RECEIPT_DIR}"
|
||||
: "${AIOS_PROVENANCE_ID:?missing AIOS_PROVENANCE_ID}"
|
||||
: "${AIOS_RUN_ID:?missing AIOS_RUN_ID}"
|
||||
: "${AIOS_TENANT_ID:?missing AIOS_TENANT_ID}"
|
||||
: "${AIOS_TASK_ID:?missing AIOS_TASK_ID}"
|
||||
: "${AIOS_AGENT_ID:?missing AIOS_AGENT_ID}"
|
||||
: "${AIOS_PRINCIPAL_ID:?missing AIOS_PRINCIPAL_ID}"
|
||||
|
||||
PYTHONPATH_VALUE="${AIOS_V2_PYTHONPATH:-/opt/aios/current/active-runtime/app}"
|
||||
|
||||
PYTHONPATH="$PYTHONPATH_VALUE${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
python3 -m aios_v2.provenance_cli authorize \
|
||||
--context-from-env \
|
||||
--store-file "$AIOS_PROVENANCE_STORE_FILE" \
|
||||
--public-key-file "$AIOS_PROVENANCE_PUBLIC_KEY_FILE" \
|
||||
--key-id "$AIOS_PROVENANCE_KEY_ID" \
|
||||
--receipt-dir "$AIOS_PROVENANCE_RECEIPT_DIR" \
|
||||
--path "$OPERATION_PATH" \
|
||||
--operation "$OPERATION_NAME" \
|
||||
--request-file "$REQUEST_FILE"
|
||||
Regular → Executable
+18
-6
@@ -8,12 +8,28 @@ STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||
TARGET_RELEASE="${1:-}"
|
||||
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
|
||||
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_healthcheck() {
|
||||
local release="$1"
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$release/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||
|
||||
@@ -53,9 +69,7 @@ mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||
systemctl daemon-reload
|
||||
systemctl start "$SERVICE"
|
||||
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
if run_healthcheck "$TARGET_PATH"; then
|
||||
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
@@ -78,9 +92,7 @@ systemctl daemon-reload
|
||||
systemctl start "$SERVICE" || true
|
||||
|
||||
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||
AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||
run_healthcheck "$PREVIOUS_PATH" || true
|
||||
fi
|
||||
|
||||
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# AIOS V2R1C2 Unified Portal Candidate
|
||||
# Install as /etc/aios-v2/aios-v2r1c2-portal.env with mode 0640.
|
||||
# Do not place secret values in this file. Use secret references only.
|
||||
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18921
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-portal
|
||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-portal/run-provenance.sqlite3
|
||||
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-portal/external-task-truth.sqlite3
|
||||
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-portal/executor-catalog.json
|
||||
AIOS_EXECUTION_PLAN_FILES=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json:/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
AIOS_EXECUTION_PLAN_FILE=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
|
||||
AIOS_RELEASE_VERSION=V2R1C2-CANDIDATE
|
||||
AIOS_TARGET_VERSION=V2R1C2
|
||||
AIOS_BASELINE_VERSION=V2R1C1
|
||||
AIOS_PREVIOUS_VERSION=V2R1C1
|
||||
AIOS_RELEASE_STATUS=CANDIDATE_VALIDATION
|
||||
AIOS_RELEASE_SOURCE_OID=REPLACE_WITH_EXACT_40_CHARACTER_GIT_OID
|
||||
AIOS_RELEASE_OID=candidate-not-tagged
|
||||
AIOS_RELEASE_TAG=NOT_CREATED
|
||||
AIOS_RELEASE_EVIDENCE_SHA256=pending
|
||||
|
||||
AIOS_OPENCLAW_CONTRACT_VERSION=aios.openclaw.command.v2
|
||||
AIOS_OPENCLAW_SIGNATURE_ALGORITHM=Ed25519
|
||||
AIOS_OPENCLAW_PLUGIN_VERSION=2.1.0-rc.4
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
|
||||
# Runtime resolves this reference through its secret store.
|
||||
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=AIOS V2R1C2 Unified Portal Candidate
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
|
||||
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json
|
||||
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
Group=aios
|
||||
WorkingDirectory=/opt/aios-v2r1c2/active-runtime/app
|
||||
EnvironmentFile=/etc/aios-v2/aios-v2r1c2-portal.env
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18921"
|
||||
ExecStartPre=/usr/bin/test -n "$AIOS_RELEASE_SOURCE_OID"
|
||||
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" != "candidate-not-tagged"
|
||||
ExecStart=/usr/bin/python3 -m aios_web.release_application
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
ReadWritePaths=/var/lib/aios-v2r1c2-portal
|
||||
StateDirectory=aios-v2r1c2-portal
|
||||
StateDirectoryMode=0750
|
||||
UMask=0027
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,14 +1,14 @@
|
||||
# AIOS V2 用户需求规格
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、Run Provenance、Dispatch、审计、部署与回滚
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录、执行证据、Run Provenance 和 Dispatch。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
|
||||
## 2. 用户角色
|
||||
|
||||
@@ -95,6 +95,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
|
||||
- 每次变化包含 revision、principal、trace、时间和审计事件。
|
||||
- 公共 API 不允许直接调用 `start`、`complete` 或 `fail` 推进权威状态。
|
||||
|
||||
### UR-007 策略与审批
|
||||
|
||||
@@ -138,12 +139,12 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
### UR-011 持久化与恢复
|
||||
|
||||
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
|
||||
租户、Agent、Task、Audit、Idempotency、Dispatch、Run Provenance、Grant 使用记录和 Execution Evidence 必须在服务重启后恢复。
|
||||
|
||||
验收:
|
||||
|
||||
- 候选版支持 SQLite WAL 原子快照。
|
||||
- 有 restart recovery 测试。
|
||||
- 候选版支持 SQLite WAL 或等价原子持久化。
|
||||
- 有 Control Plane 与 Dispatch restart recovery 测试。
|
||||
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||
|
||||
### UR-012 审计与合规
|
||||
@@ -155,6 +156,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||
- OpenClaw/Hermes Runtime 生命周期。
|
||||
- GitOps、部署和回滚动作。
|
||||
- Provenance Grant、Scope、Authorization Receipt 和 Dispatch 结果摘要。
|
||||
|
||||
### UR-013 密钥安全
|
||||
|
||||
@@ -165,6 +167,7 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||
- Secret 文件权限为 `0600` 或更严格。
|
||||
- Secret Scanner 默认拒绝可疑提交。
|
||||
- OpenClaw Command 和 Run Provenance 使用 Ed25519;生产应用只读取公钥。
|
||||
|
||||
### UR-014 部署与回滚
|
||||
|
||||
@@ -187,15 +190,102 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Traceability Matrix 必须指向当前代码和测试。
|
||||
- 新架构决策必须增加或更新 ADR。
|
||||
|
||||
### UR-016 Workflow Run Provenance
|
||||
|
||||
每次生产 Run 必须具有一个可验证、可撤销、可过期的 Workflow Provenance,用于绑定 Run、Tenant、Task、Agent 与 Principal,但父 Workflow 本身不得授予操作权限。
|
||||
|
||||
验收:
|
||||
|
||||
- Workflow Provenance 使用 Ed25519 签名。
|
||||
- `allowed_paths` 与 `allowed_git_operations` 为空。
|
||||
- 上下文、签名、TTL、Nonce、Key ID 不合法时默认拒绝。
|
||||
- 撤销父 Workflow 后全部子 Grant 失效。
|
||||
|
||||
### UR-017 最小权限子 Grant
|
||||
|
||||
所有生产副作用必须通过与单一 ExecutionPath 对应的子 Grant 授权。
|
||||
|
||||
验收:
|
||||
|
||||
- Runtime、Evidence、Git、Deployment、Rollback、Audit 和 Code Change 使用独立 Grant。
|
||||
- 一个 Grant 只能包含一个路径。
|
||||
- Git Grant 必须限定具体 GitOperation。
|
||||
- 子 Grant 具有独立 TTL、撤销、状态和重放防护。
|
||||
|
||||
### UR-018 Runtime Result 信任绑定
|
||||
|
||||
OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完全匹配时,才能更新权威 Task。
|
||||
|
||||
验收:
|
||||
|
||||
- 至少校验 tenant、task、agent、principal、trace、runtime、runtime execution、provenance 和 run。
|
||||
- 不同终态结果摘要不得覆盖已有终态。
|
||||
- Hermes 终态结果必须来自持有 Job Lease 的 Worker。
|
||||
- Hermes Worker 生命周期验收入口必须是默认关闭的内部接口,仅接受可信
|
||||
`runtime` 或 `platform_admin` 身份;普通 Operator、浏览器和公共 API 不得直接调用。
|
||||
- 每次 Claim/Start/Heartbeat/Complete/Fail/Cancel 必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
- Complete/Fail/Cancel 必须通过 `ExecutionOrchestrator.reconcile_hermes` 更新权威
|
||||
Task/Dispatch,并同步生成 Audit/Event 与 Execution Evidence。
|
||||
- Lease 到期必须持久化清除旧 Owner、递增 revision 并允许安全重领;旧 Worker
|
||||
后续 Result 必须拒绝。
|
||||
- Runtime callback 审计不得绕过 Orchestrator Reconcile。
|
||||
|
||||
### UR-019 Dispatch 持久化与恢复
|
||||
|
||||
Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并继续严格 Reconcile。
|
||||
|
||||
验收:
|
||||
|
||||
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
|
||||
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
|
||||
- 终态 Dispatch 不得迁移到不同终态。
|
||||
- Hermes Job、Result、Lease、幂等键和 Worker heartbeat 必须与 Dispatch 一样在
|
||||
服务重启后恢复;不得以只存在于进程内的 Map 作为在线验收依据。
|
||||
- 有 SQLite Repository 重启恢复测试。
|
||||
|
||||
### UR-020 OpenClaw Ed25519 命令合同
|
||||
|
||||
OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 HMAC 作为新命令信任根。
|
||||
|
||||
验收:
|
||||
|
||||
- 合同版本为 `aios.openclaw.command.v2`。
|
||||
- 签名覆盖 Key ID、算法、公钥 Fingerprint、Tenant、Task、Trace、Payload、Idempotency 与时间字段。
|
||||
- Key ID/Fingerprint/签名/过期或时钟偏差不合法时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
|
||||
### UR-021 服务级权限隔离
|
||||
|
||||
每个生产服务或 Worker 只能加载其职责所需的 Grant,不得加载全部 Grant 备用。
|
||||
|
||||
验收:
|
||||
|
||||
- 默认 AIOS Application 仅加载 Runtime、Evidence 与 Audit Grant。
|
||||
- Git、Deployment、Rollback 与 Code Change 由独立 Scope 加载。
|
||||
- 空 Scope、多余 Grant 映射和未声明路径调用全部拒绝。
|
||||
- Authorization Receipt 记录 Grant ID、Writer ID 和 Scope。
|
||||
|
||||
### UR-022 CLI 单 Grant 授权
|
||||
|
||||
运维 CLI 的一次授权操作只能加载一个与请求路径一致的 Grant。
|
||||
|
||||
验收:
|
||||
|
||||
- `authorize` 使用一个 `--path` 和一个 `--grant-id`。
|
||||
- Grant 类型与 Path 不一致时拒绝。
|
||||
- CLI 不提供一次加载七个生产 Grant 的授权入口。
|
||||
- 旧全权限 `issue` 仅允许候选兼容,不得进入生产流程。
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### NFR-001 安全
|
||||
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计、可撤销 Provenance 和 Runtime 结果绑定。
|
||||
|
||||
### NFR-002 可靠性
|
||||
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、Dispatch 恢复、健康检查和回滚。
|
||||
|
||||
### NFR-003 可维护性
|
||||
|
||||
@@ -203,28 +293,36 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
### NFR-004 可扩展性
|
||||
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter、Provenance Verifier 与 Writer 均通过接口扩展。
|
||||
|
||||
### NFR-005 可运维性
|
||||
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告、Grant 状态和 Receipt。
|
||||
|
||||
### NFR-006 性能候选指标
|
||||
|
||||
候选版上线前至少验证:
|
||||
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间、Grant 授权延迟和 Dispatch 恢复时间。
|
||||
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||
|
||||
## 5. 本候选版本范围
|
||||
|
||||
### 已纳入代码
|
||||
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、Run Provenance、Least-Privilege Grant、Scoped Writer、Dispatch Repository、部署和回滚资产。
|
||||
|
||||
### 已纳入测试代码
|
||||
|
||||
- Control Plane/Runtime 候选流程。
|
||||
- OpenClaw Ed25519 签名与验证。
|
||||
- Hermes Worker Lease 与严格 Reconcile。
|
||||
- Dispatch SQLite 重启恢复。
|
||||
- Workflow/Grant 撤销、过期、重放和服务 Scope 隔离。
|
||||
|
||||
### 尚需环境验证
|
||||
|
||||
- 精确测试执行证据。
|
||||
- 完整 Acceptance 实际执行证据。
|
||||
- OpenClaw 真实 Runtime 联调。
|
||||
- 生产数据库/队列替换或容量验证。
|
||||
- 预发布性能、安全、重启和回滚演练。
|
||||
@@ -234,10 +332,11 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||
2. 操作员创建任务,审批人批准。
|
||||
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
|
||||
4. Runtime 执行并返回结果。
|
||||
5. AIOS 更新 Task、Audit 和 Execution Evidence。
|
||||
6. 租户用户在 Web Console 查看完整链路。
|
||||
7. 跨租户访问被拒绝。
|
||||
8. 服务重启后状态恢复。
|
||||
9. 候选部署健康检查通过,回滚演练成功。
|
||||
3. Owner 或受控签发端签发 Workflow Provenance 与所需最小权限 Grant。
|
||||
4. AIOS Orchestrator 持久化 Dispatch,并创建 Ed25519 命令或租户分区 Job。
|
||||
5. Runtime 执行并返回与 Dispatch 完全匹配的结果。
|
||||
6. AIOS 更新 Task、Audit、Authorization Receipt 和 Execution Evidence。
|
||||
7. 租户用户在 Web Console 查看完整链路。
|
||||
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
|
||||
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
|
||||
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
|
||||
|
||||
+286
-180
@@ -1,29 +1,37 @@
|
||||
# AIOS V2 模块契约
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选实现契约基线
|
||||
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选契约基线
|
||||
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
|
||||
|
||||
## 1. 通用上下文契约
|
||||
核心模型名称与实现保持逐字同步:`RequestContext` 承载可信租户、主体、角色、trace、
|
||||
session 与 policy version;`TaskState` 是 AIOS 权威 Task 生命周期枚举,Runtime Worker
|
||||
不得直接写入或复制该状态机。
|
||||
|
||||
## 1. 通用可信上下文契约
|
||||
|
||||
所有控制与执行请求必须携带以下上下文:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `tenant_id` | 是 | 权威租户标识 |
|
||||
| `principal_id` | 控制请求是 | 已认证主体 |
|
||||
| `principal_id` | 控制和 Reconcile 是 | 已认证主体 |
|
||||
| `trace_id` | 是 | 全链路追踪 |
|
||||
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||
| `session_id` | Web/交互请求建议 | 用户会话 |
|
||||
| `agent_id` | 执行和 Provenance 是 | 任务绑定 Agent |
|
||||
| `provenance_id` | 生产副作用操作是 | 父 Workflow Provenance |
|
||||
| `run_id` | 生产副作用操作是 | 本次执行 Run |
|
||||
| `dispatch_id` | Runtime Result 是 | 持久化 Dispatch 记录 |
|
||||
| `policy_version` | 控制请求是 | 策略版本 |
|
||||
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||
|
||||
规则:
|
||||
|
||||
- 缺少必填字段时 fail closed。
|
||||
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
|
||||
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
|
||||
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
|
||||
- 客户端不得覆盖可信代理或 AIOS 生成的租户、身份和追踪字段。
|
||||
- Runtime 返回的 tenant/task/trace/runtime execution 必须与持久 Dispatch 完全匹配。
|
||||
- Runtime Result 还必须匹配 Principal、Parent Provenance 和 Run。
|
||||
- 所有持久化键、缓存键、队列分区、Receipt 和 Evidence 路径必须包含租户命名空间。
|
||||
|
||||
## 2. Tenant Contract
|
||||
|
||||
@@ -41,9 +49,9 @@ updated_at
|
||||
约束:
|
||||
|
||||
- `tenant_id` 不可为空且不可变。
|
||||
- `status` 至少支持 `active` 和受控停用状态。
|
||||
- Tenant 只由 AIOS Control Plane 创建和维护。
|
||||
- Runtime 不得创建租户。
|
||||
- Runtime 不得创建或修改租户。
|
||||
- 跨租户读取默认返回拒绝或资源不存在,不得泄露存在性。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||
|
||||
@@ -68,8 +76,7 @@ updated_at
|
||||
- Agent 属于唯一租户。
|
||||
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
|
||||
- `capabilities` 是允许调度的动作集合。
|
||||
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
|
||||
- 不得在 Agent metadata 中保存明文凭据。
|
||||
- `metadata` 仅保存非敏感引用,不得保存明文凭据。
|
||||
|
||||
## 4. Task Contract
|
||||
|
||||
@@ -93,7 +100,7 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### 4.2 状态机
|
||||
### 4.2 权威状态机
|
||||
|
||||
```text
|
||||
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
@@ -104,22 +111,22 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
允许转换:
|
||||
|
||||
| 当前 | 允许目标 |
|
||||
|---|---|
|
||||
| CREATED | APPROVED, CANCELLED |
|
||||
| APPROVED | RUNNING, CANCELLED |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED |
|
||||
| COMPLETED | 无 |
|
||||
| FAILED | 无 |
|
||||
| CANCELLED | 无 |
|
||||
| 当前 | 允许目标 | 权威入口 |
|
||||
|---|---|---|
|
||||
| CREATED | APPROVED, CANCELLED | Control Plane Approval/Cancel |
|
||||
| APPROVED | RUNNING, CANCELLED | Orchestrator Dispatch / Control Plane Cancel |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED | Orchestrator Reconcile / Control Plane Cancel |
|
||||
| COMPLETED | 无 | 终态 |
|
||||
| FAILED | 无 | 终态 |
|
||||
| CANCELLED | 无 | 终态 |
|
||||
|
||||
约束:
|
||||
|
||||
- 每次转换必须校验 `revision`。
|
||||
- COMPLETED 必须带 `result`。
|
||||
- FAILED 必须带 `error`。
|
||||
- COMPLETED 必须带 `result`;FAILED 必须带 `error`。
|
||||
- 每次转换生成 AuditEvent。
|
||||
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
|
||||
- Runtime 不得直接写 Task Store。
|
||||
- 公共 API 不得直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`。
|
||||
|
||||
## 5. Policy Contract
|
||||
|
||||
@@ -158,17 +165,166 @@ created_at
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户查询。
|
||||
- `details` 不得包含明文凭据。
|
||||
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
|
||||
- 按租户写入和查询。
|
||||
- `details` 不得包含明文凭据、私钥或完整敏感 Payload。
|
||||
- Runtime callback 可以生成非权威审计事件,但不得单独触发 Task 状态变化。
|
||||
- Task 状态变化必须经过 Orchestrator 严格 Reconcile。
|
||||
- 生产版需增加不可变外部固化和完整性验证。
|
||||
|
||||
## 7. OpenClaw Contract
|
||||
## 7. Workflow Provenance Contract
|
||||
|
||||
### 7.1 OpenClawCommand
|
||||
### 7.1 RunProvenance
|
||||
|
||||
父 Workflow Provenance 只绑定身份与 Run,不授予操作权限:
|
||||
|
||||
```text
|
||||
contract_version
|
||||
provenance_id
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_paths = []
|
||||
allowed_git_operations = []
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 使用 Ed25519 签名。
|
||||
- `allowed_paths` 和 `allowed_git_operations` 必须为空。
|
||||
- 撤销或过期父 Workflow 后,全部子 Grant 自动失效。
|
||||
- 私钥只存在签发端,生产应用只读取公钥。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/provenance.py`
|
||||
|
||||
## 8. Provenance Grant Contract
|
||||
|
||||
### 8.1 ProvenanceGrant
|
||||
|
||||
```text
|
||||
grant_id
|
||||
parent_provenance_id
|
||||
grant_type
|
||||
execution_path
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_git_operations
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
Grant 类型与唯一允许路径:
|
||||
|
||||
| GrantType | ExecutionPath |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
约束:
|
||||
|
||||
- 一个 Grant 只允许一个路径。
|
||||
- Git Grant 必须显式声明具体 GitOperation;非 Git Grant 不得包含 Git 操作。
|
||||
- 子 Grant 身份字段必须与父 Workflow 完全一致。
|
||||
- 子 Grant TTL 不得超过父 Workflow。
|
||||
- `(grant_id, path, git_operation, request_digest)` 重复使用时拒绝。
|
||||
- Grant 支持独立 `issued/verified/used/expired/revoked` 生命周期。
|
||||
|
||||
实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
|
||||
## 9. Service Scoped Writer Contract
|
||||
|
||||
生产进程只加载职责所需 Grant:
|
||||
|
||||
- AIOS Application:`runtime_execute`、`evidence_write`、`audit`。
|
||||
- Git Worker:`git_operation`,代码提交场景另需独立 `code_change` Grant。
|
||||
- Deployment Worker:`deployment`。
|
||||
- Rollback Worker:`rollback`。
|
||||
- CLI 单次授权:一个 `--path` + 一个 `--grant-id`。
|
||||
|
||||
约束:
|
||||
|
||||
- 空 Scope 拒绝。
|
||||
- 多余 Grant 映射拒绝。
|
||||
- 未配置路径的方法调用拒绝。
|
||||
- Receipt 必须记录 `grant_id`、`writer_id` 和服务 Scope。
|
||||
|
||||
## 10. Dispatch Contract
|
||||
|
||||
### 10.1 DispatchRecord
|
||||
|
||||
```text
|
||||
dispatch_id
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
runtime
|
||||
runtime_execution_id
|
||||
trace_id
|
||||
provenance_id
|
||||
run_id
|
||||
authorization_id
|
||||
state
|
||||
expected_worker_id
|
||||
result_digest
|
||||
created_at
|
||||
updated_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
PREPARED
|
||||
RUNNING
|
||||
QUEUED
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- Dispatch 必须在 Runtime 副作用之前持久化。
|
||||
- 同一租户的 Task 和 Runtime Execution 各只能绑定一个 Dispatch。
|
||||
- 终态 Dispatch 不得跳转到不同终态。
|
||||
- 相同终态结果允许幂等读取,不同结果摘要拒绝。
|
||||
- SQLite Repository 必须支持按 Task 和 Runtime Execution 恢复。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/dispatch_store.py`
|
||||
|
||||
## 11. OpenClaw Contract
|
||||
|
||||
### 11.1 OpenClawCommand v2
|
||||
|
||||
```text
|
||||
contract_version = aios.openclaw.command.v2
|
||||
signature_algorithm = Ed25519
|
||||
command_id
|
||||
tenant_id
|
||||
task_id
|
||||
@@ -179,29 +335,24 @@ idempotency_key
|
||||
created_at
|
||||
expires_at
|
||||
key_id
|
||||
public_key_fingerprint
|
||||
signature
|
||||
```
|
||||
|
||||
签名覆盖:
|
||||
|
||||
- command_id
|
||||
- tenant_id
|
||||
- task_id
|
||||
- trace_id
|
||||
- action
|
||||
- payload
|
||||
- idempotency_key
|
||||
- created_at / expires_at
|
||||
- contract_version / key_id
|
||||
签名覆盖全部未签名字段,包括算法、Key ID 和公钥 Fingerprint。
|
||||
|
||||
约束:
|
||||
|
||||
- 使用规范化 JSON 和 HMAC-SHA256。
|
||||
- 只接受已批准任务生成的命令。
|
||||
- 过期、未来时间超差、签名错误命令必须拒绝。
|
||||
- `key_id` 只引用运行时材料,不包含材料本身。
|
||||
- 使用规范化 JSON 与 Ed25519。
|
||||
- HMAC-SHA256 不再是生产命令合同。
|
||||
- 只接受由已批准任务和已授权 Dispatch 生成的命令。
|
||||
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
- `key_id` 只引用密钥,不包含密钥材料。
|
||||
- Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
|
||||
不得在已有批准 Key ID 时回落为默认值。
|
||||
|
||||
### 7.2 OpenClawResult
|
||||
### 11.2 OpenClawResult
|
||||
|
||||
```text
|
||||
command_id
|
||||
@@ -217,49 +368,30 @@ runtime_id
|
||||
attempts
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
ACCEPTED
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
TIMED_OUT
|
||||
REJECTED
|
||||
```
|
||||
终态:`COMPLETED`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`REJECTED`。
|
||||
|
||||
约束:
|
||||
|
||||
- Adapter 对外只接受终态结果用于 reconcile。
|
||||
- 身份字段不匹配时拒绝回写。
|
||||
- 结果通过 Orchestrator 更新 AIOS Task。
|
||||
|
||||
### 7.3 RuntimeHealth
|
||||
|
||||
```text
|
||||
runtime
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_tasks
|
||||
workspace_isolated
|
||||
credential_ref_configured
|
||||
details
|
||||
```
|
||||
|
||||
健康策略:
|
||||
|
||||
- heartbeat 不得超时。
|
||||
- workspace isolation 与 credential reference 必须符合策略。
|
||||
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
|
||||
- Result 必须匹配持久 Dispatch 的 command、tenant、task、trace、principal、provenance 和 run。
|
||||
- 只有 Orchestrator 可以更新 AIOS Task。
|
||||
- callback 审计不是权威 Reconcile。
|
||||
|
||||
实现:`active-runtime/app/aios_openclaw/`
|
||||
|
||||
## 8. Hermes Contract
|
||||
## 12. Hermes Contract
|
||||
|
||||
### 8.1 HermesJob
|
||||
### 12.0 内部生命周期入口
|
||||
|
||||
- 路径:`/api/v2/internal/runtime/hermes/lifecycle/{claim,start,heartbeat,complete,fail,cancel,status}`。
|
||||
- `AIOS_HERMES_LIFECYCLE_API_ENABLED` 默认关闭;关闭时按不存在的路由 fail closed。
|
||||
- 仅可信反向代理注入且具备 `runtime` 或 `platform_admin` 角色的身份可调用。
|
||||
- 所有写请求必须携带并严格匹配 tenant、task、trace、principal、provenance、run、
|
||||
dispatch、job、cell、profile;Claim 还必须指定精确 job,不能从分区中误领其他 Job。
|
||||
- Worker 不得直接修改 Task Store。Complete、Fail 和 Cancel 产生的 HermesResult
|
||||
必须经持久 Dispatch 校验并由 `ExecutionOrchestrator.reconcile_hermes` 终态化。
|
||||
- 每次成功生命周期写入同时落盘 Hermes 状态、Audit/Event 和 Execution Evidence。
|
||||
|
||||
### 12.1 HermesJob
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -280,24 +412,18 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
状态机:
|
||||
|
||||
```text
|
||||
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
|
||||
| | |
|
||||
+---------+----------+-> CANCELLED
|
||||
+-> FAILED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||
- claim 只能领取同分区 Job。
|
||||
- Worker ID 不得跨分区复用。
|
||||
- Lease 到期 Job 回到 QUEUED。
|
||||
- Hermes Store 使用 `AIOS_V2_STATE_DIR/hermes.sqlite3` 保存 Job、Result、Lease、
|
||||
Idempotency 和 Worker heartbeat;Adapter 仍是唯一 Queue/Lease 状态机。
|
||||
- Lease 到期重排必须清空 `assigned_worker_id`、清空 `lease_expires_at`、递增 revision
|
||||
并持久化。重启后由显式 lifecycle status/claim 或健康检查安全触发重排。
|
||||
- revision 冲突不得覆盖。
|
||||
|
||||
### 8.2 HermesResult
|
||||
### 12.2 HermesResult
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -314,30 +440,19 @@ finished_at
|
||||
|
||||
约束:
|
||||
|
||||
- COMPLETED 必须有 output。
|
||||
- FAILED 必须有 error。
|
||||
- 只允许 AIOS Orchestrator reconcile。
|
||||
|
||||
### 8.3 HermesHealth
|
||||
|
||||
```text
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_workers
|
||||
active_jobs
|
||||
queued_jobs
|
||||
tenant_partitions
|
||||
profile_isolation_enforced
|
||||
details
|
||||
```
|
||||
- 终态 Result 必须携带 Worker ID。
|
||||
- Worker 必须持有 Job Lease。
|
||||
- Result 必须匹配 Dispatch、Principal、Parent Provenance 和 Run。
|
||||
- 相同 Worker、相同终态和相同 output/error 的重复提交返回原 Result;不同终态或
|
||||
不同结果内容返回 Conflict,不得覆盖已持久化终态。
|
||||
- 只允许内部入口 `POST /api/v2/internal/runtime/hermes/reconcile` 处理。
|
||||
- 旧入口 `POST /api/v2/hermes/results/reconcile` 返回 `410 Gone`。
|
||||
|
||||
实现:`active-runtime/app/aios_hermes/`
|
||||
|
||||
## 9. Execution Evidence Contract
|
||||
## 13. Execution Evidence Contract
|
||||
|
||||
### 9.1 ExecutionEvidence
|
||||
### 13.1 ExecutionEvidence
|
||||
|
||||
```text
|
||||
execution_id
|
||||
@@ -357,53 +472,37 @@ result_summary
|
||||
claimed_at
|
||||
heartbeat_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态建议:
|
||||
|
||||
```text
|
||||
claimed
|
||||
running
|
||||
blocked
|
||||
complete
|
||||
failed
|
||||
cancelled
|
||||
provenance_id
|
||||
run_id
|
||||
last_authorization_id
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户存储和查询。
|
||||
- 每个阻塞必须记录原因、责任方和解除条件。
|
||||
- 每次结果必须记录 branch、commit、测试命令和状态。
|
||||
- 不得包含 Secret 或完整敏感 payload。
|
||||
- 生产 `claim/heartbeat/complete` 落盘前必须通过 Evidence Grant。
|
||||
- Evidence ID 不得被不同 Task、Agent 或 Provenance Context 复用。
|
||||
- 不得包含 Secret 或完整敏感 Payload。
|
||||
|
||||
## 10. GitOps Status Contract
|
||||
实现:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||
|
||||
```text
|
||||
available
|
||||
service_status
|
||||
workspace_mode
|
||||
workspace_branch
|
||||
workspace_ref
|
||||
workspace_dirty
|
||||
candidate_branch
|
||||
release_channel
|
||||
release_version
|
||||
sync_status
|
||||
details
|
||||
```
|
||||
## 14. Git Writer Contract
|
||||
|
||||
允许操作:`READ_REF`、`FETCH`、`CREATE_BRANCH`、`COMMIT`、`MERGE`、`PUSH`、`UPDATE_EVIDENCE`,具体能力由 Git Grant 限定。
|
||||
|
||||
约束:
|
||||
|
||||
- Web 响应不得包含 remote URL、凭据或 stderr。
|
||||
- Controller 必须 `detached + clean`。
|
||||
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
|
||||
- 禁止直接操作 `main` 或 `master`。
|
||||
- 所有命令使用参数数组,禁止 Shell 字符串拼接。
|
||||
- Commit 和 Evidence Commit 必须先计算文件内容摘要并完成 Grant 授权,再执行 `git add`。
|
||||
- `UPDATE_EVIDENCE` 只允许 `.agent/` 路径。
|
||||
- 输出必须脱敏并截断。
|
||||
|
||||
## 11. Web/API Contract
|
||||
实现:`active-runtime/app/aios_v2/provenance_git.py`
|
||||
|
||||
### 11.1 身份头
|
||||
## 15. Web/API Contract
|
||||
|
||||
可信代理注入:
|
||||
### 15.1 可信身份头
|
||||
|
||||
```text
|
||||
X-AIOS-Authenticated
|
||||
@@ -413,14 +512,17 @@ X-AIOS-Roles
|
||||
X-AIOS-Trace-ID
|
||||
X-AIOS-Session-ID
|
||||
X-AIOS-Policy-Version
|
||||
X-AIOS-Provenance-ID
|
||||
X-AIOS-Run-ID
|
||||
```
|
||||
|
||||
生产约束:
|
||||
|
||||
- 代理必须删除客户端同名头后重新注入。
|
||||
- 可信代理必须删除客户端同名头后重新注入。
|
||||
- 应用不得直接暴露公网端口。
|
||||
- Header 与 Body 中重复的 Provenance/Run 值必须一致。
|
||||
|
||||
### 11.2 核心 API
|
||||
### 15.2 核心 API
|
||||
|
||||
```text
|
||||
GET /api/v2/health
|
||||
@@ -432,71 +534,75 @@ POST /api/v2/tasks
|
||||
GET /api/v2/tasks
|
||||
GET /api/v2/tasks/{id}
|
||||
POST /api/v2/tasks/{id}/approve
|
||||
POST /api/v2/tasks/{id}/start
|
||||
POST /api/v2/tasks/{id}/complete
|
||||
POST /api/v2/tasks/{id}/fail
|
||||
POST /api/v2/tasks/{id}/cancel
|
||||
POST /api/v2/tasks/{id}/dispatch
|
||||
GET /api/v2/audit
|
||||
GET /api/v2/runtime
|
||||
GET /api/v2/executions
|
||||
GET /api/v2/gitops
|
||||
POST /api/v2/hermes/results/reconcile
|
||||
POST /api/v2/internal/runtime/hermes/reconcile
|
||||
```
|
||||
|
||||
## 12. Persistence Contract
|
||||
公共生命周期旁路:
|
||||
|
||||
```text
|
||||
POST /api/v2/tasks/{id}/start -> 403
|
||||
POST /api/v2/tasks/{id}/complete -> 403
|
||||
POST /api/v2/tasks/{id}/fail -> 403
|
||||
```
|
||||
|
||||
## 16. Persistence Contract
|
||||
|
||||
候选版:
|
||||
|
||||
- SQLite WAL
|
||||
- `BEGIN IMMEDIATE`
|
||||
- 原子 snapshot
|
||||
- schema version
|
||||
- restart restore
|
||||
|
||||
持久化范围:
|
||||
|
||||
- tenants
|
||||
- agents
|
||||
- tasks
|
||||
- audit
|
||||
- idempotency
|
||||
- Control Plane:SQLite Snapshot。
|
||||
- Dispatch:独立 SQLite Repository。
|
||||
- Provenance/Grant/Usage/Event:SQLite WAL。
|
||||
- Evidence/Receipt:租户隔离原子文件写入。
|
||||
- 写事务使用原子替换或 `BEGIN IMMEDIATE`。
|
||||
|
||||
生产替换要求:
|
||||
|
||||
- 不改变 Domain/API Contract。
|
||||
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
|
||||
- 提供 migration、backup、restore、HA、隔离和容量测试。
|
||||
- 恢复时保留已使用 Grant 与终态 Dispatch,禁止旧授权重放。
|
||||
|
||||
## 13. Deployment Contract
|
||||
## 17. Deployment Contract
|
||||
|
||||
### systemd
|
||||
|
||||
- 非 root `aios` 用户。
|
||||
- 状态目录 `/var/lib/aios-v2`。
|
||||
- 最小权限和文件系统保护。
|
||||
- 私钥通过受保护 credential 注入,不写入 Git 或普通环境文件。
|
||||
- 私钥文件必须是 regular file、禁止 symlink、权限 `0600` 或更严格。
|
||||
|
||||
### Docker
|
||||
|
||||
- 非 root UID/GID。
|
||||
- read-only root filesystem。
|
||||
- drop all capabilities。
|
||||
- 独立状态 volume。
|
||||
- 独立状态 volume 和 Secret mount。
|
||||
|
||||
### Rollback
|
||||
|
||||
- 回滚前备份状态。
|
||||
- 默认只回滚代码。
|
||||
- 状态恢复需显式授权。
|
||||
- 回滚后必须执行 healthcheck。
|
||||
- 状态恢复需独立 Rollback Grant 和显式批准。
|
||||
- 回滚后必须执行 Healthcheck 与恢复验证。
|
||||
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
|
||||
验收测试必须阻止非执行模式进入候选包。
|
||||
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
|
||||
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
|
||||
|
||||
## 14. 契约变更规则
|
||||
## 18. 契约变更规则
|
||||
|
||||
任何以下变化必须在同一提交或同一分支更新本文档:
|
||||
以下变化必须在同一分支更新本文档、追踪矩阵和相关 ADR:
|
||||
|
||||
- 字段、状态、Header、API、错误语义;
|
||||
- 租户或安全约束;
|
||||
- 租户、安全和 Provenance 约束;
|
||||
- OpenClaw/Hermes 边界;
|
||||
- Persistence 或 Deployment Contract;
|
||||
- Evidence/Audit 格式。
|
||||
- Dispatch、Persistence、Deployment、Rollback Contract;
|
||||
- Evidence、Audit、Receipt 格式;
|
||||
- 服务 Scope 或 Grant 类型。
|
||||
|
||||
不兼容变化必须新增 ADR 和版本号,不得静默修改。
|
||||
不兼容变化必须新增 ADR 和版本号,不得静默修改。正式 Gate A 结论必须基于实际执行 Acceptance 和保存的证据,不得仅凭测试文件存在宣布通过。
|
||||
|
||||
@@ -1,113 +1,79 @@
|
||||
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:实现与测试代码已提交;测试执行和 Acceptance 证据待完成
|
||||
- 基线需求:`UR-001`~`UR-022`
|
||||
- 判定规则:测试文件存在不等于测试通过;只有实际执行并保存证据后,状态才能改为“已验证”
|
||||
|
||||
## 1. 追踪规则
|
||||
## 1. 状态定义
|
||||
|
||||
每条需求至少具备:
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| 已实现 | 代码、配置或文档已经提交到当前加固分支 |
|
||||
| 测试已提交 | 自动测试代码已经提交,但本报告没有实际执行结果 |
|
||||
| 待验收 | 需要运行 `deploy/aios-v2/acceptance/run-all.sh` 或对应预发布检查并保存证据 |
|
||||
| 未批准 | 尚未获得 Owner 的 Merge、Tag 或生产部署批准 |
|
||||
|
||||
1. 需求编号;
|
||||
2. 架构章节;
|
||||
3. 实现路径;
|
||||
4. 测试路径;
|
||||
5. 部署/运维资产;
|
||||
6. 验收状态。
|
||||
## 2. 用户需求追踪
|
||||
|
||||
状态定义:
|
||||
|
||||
- `Implemented`:代码已进入 Candidate 分支;
|
||||
- `Test-defined`:测试代码已进入 Candidate 分支;
|
||||
- `Test-verified`:精确 commit 的测试已运行并保存证据;
|
||||
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
|
||||
- `Production released`:Owner 批准并完成 merge/tag/deploy。
|
||||
|
||||
## 2. 核心追踪矩阵
|
||||
|
||||
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|
||||
| 需求 | 架构/决策 | 主要实现 | 测试代码 | 部署/验收入口 | 当前状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
|
||||
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
|
||||
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
|
||||
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
|
||||
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
|
||||
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
|
||||
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
|
||||
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
|
||||
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
|
||||
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
|
||||
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
|
||||
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
|
||||
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
|
||||
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
|
||||
| UR-001 唯一控制面 | ADR-001;模块契约第 2~6 节 | `active-runtime/app/aios_v2/control_plane.py`、`gateway.py`、`orchestrator.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/acceptance/run-all.sh`;公共生命周期旁路负向检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-002 复用现有能力 | ADR-002 | `active-runtime/app/aios_openclaw/`、`active-runtime/app/aios_hermes/`、`active-runtime/app/aios_v2/orchestrator.py` | `test_candidate_flow.py` | OpenClaw/Hermes 联调 | 已实现;真实 Runtime 联调待验收 |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | ADR-002、ADR-010;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Lifecycle/Reconcile | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 默认关闭的内部 Runtime API;Runtime audit/evidence 检查 | 已实现;Hermes 专项 17/17 |
|
||||
| UR-004 全面 Web 化 | 目标架构;模块契约第 15 节 | `active-runtime/app/aios_v2/app.py`、`console.py`、`gateway.py` | `test_candidate_flow.py` | Web/API 冒烟、Healthcheck | 已实现;UI/浏览器验收待执行 |
|
||||
| UR-005 多租户隔离 | ADR-004 | `control_plane.py`、`persistence.py`、`execution_tracking.py`、`dispatch_store.py` | `test_candidate_flow.py::test_cross_tenant_task_read_is_denied` | 跨租户负向 Acceptance | 已实现;测试已提交;待验收 |
|
||||
| UR-006 权威任务生命周期 | ADR-001;模块契约第 4 节 | `control_plane.py`、`gateway.py`、`orchestrator.py` | `test_candidate_flow.py` | `/start`、`/complete`、`/fail` 应返回拒绝;Runtime 严格 Reconcile | 已实现;测试/接口验收待执行 |
|
||||
| UR-007 策略与审批 | ADR-001 | `control_plane.py`、`gateway.py` | `test_candidate_flow.py` | Approval、deny audit、policy version 检查 | 已实现;策略场景验收待执行 |
|
||||
| UR-008 幂等与并发安全 | 模块契约第 4、10、12 节 | Task idempotency/revision、Hermes lease/revision、Dispatch 唯一约束 | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | Lease 到期重排、换主、相同终态幂等、不同终态冲突 | 已实现;专项测试通过 |
|
||||
| UR-009 可观测与执行证据 | 模块契约第 6、13 节 | `execution_tracking.py`、`gitops_status.py`、`console.py` | `test_candidate_flow.py::test_execution_tracking_persists_claim_heartbeat_and_result` | `/api/v2/executions`、Evidence Receipt 检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-010 GitOps 唯一变更流程 | ADR-003 | `active-runtime/app/aios_v2/provenance_git.py`、GitOps 工具与任务资产 | `test_provenance.py`、Grant 隔离测试 | Branch/Commit/Push 门禁、Controller clean、OID 同步 | 已实现候选控制;环境 GitOps 验收待执行 |
|
||||
| UR-011 持久化与恢复 | ADR-006、ADR-010;模块契约第 10、12、16 节 | `persistence.py`、`dispatch_store.py`、`aios_hermes/adapter.py::SQLiteHermesStore`、Provenance/Grant Store | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 运行中 Job 重启恢复、重启后 Reconcile、SQLite quick_check | Hermes/Dispatch/Task 恢复测试通过;生产 HA/容量待验收 |
|
||||
| UR-012 审计与合规 | ADR-001、ADR-004、ADR-009 | `control_plane.py` AuditEvent、`app.py` callback audit、Provenance/Grant Event/Receipt | `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` | `/api/v2/audit`、Receipt/Event 完整性检查 | 已实现候选审计;不可变外部固化待预发布 |
|
||||
| UR-013 密钥安全 | ADR-009;模块契约第 6、11、17 节 | Ed25519 Signer/Verifier、protected file reader、SecretRef 配置 | `test_provenance.py`、`test_candidate_flow.py` | Secret scanner、权限检查、容器/systemd credential 检查 | 已实现;环境密钥注入验收待执行 |
|
||||
| UR-014 部署与回滚 | 迁移与交付文档;模块契约第 17 节 | `deploy/aios-v2/systemd/`、`docker/`、`healthcheck/`、`rollback/` | Acceptance 脚本与候选测试 | systemd/Docker 安装、Healthcheck、备份与回滚演练 | 资产已提交;实际部署/回滚未执行 |
|
||||
| UR-015 文档与代码同步 | ADR-005 | `tools/aios_v2_docs_code_check.py`、`docs/aios-v2/` | 文档门禁脚本 | Acceptance Runner 调用文档门禁 | 已实现;门禁尚未实际执行 |
|
||||
| UR-016 Workflow Run Provenance | ADR-009;模块契约第 7 节 | `active-runtime/app/aios_v2/provenance.py`、`provenance_grants.py::WorkflowProvenanceIssuer` | `test_provenance.py`、`test_provenance_grant_isolation.py::test_parent_workflow_has_no_operational_rights` | CLI `issue-workflow`、状态/撤销检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-017 最小权限子 Grant | ADR-009;模块契约第 8 节 | `provenance_grants.py`、`provenance_scoped.py` | `test_provenance_grant_isolation.py` | `issue-grant`、Grant status/revoke、Receipt 检查 | 已实现;专项测试已提交;待验收 |
|
||||
| UR-018 Runtime Result 信任绑定 | ADR-009、ADR-010;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Lifecycle/Reconcile | `test_hermes_live_lifecycle.py` | tenant/task/trace/principal/provenance/run/dispatch/job/cell/profile/worker 负向矩阵 | 已实现;专项 17/17 |
|
||||
| UR-019 Dispatch 持久化与恢复 | ADR-009、ADR-010;模块契约第 10、12 节 | `dispatch_store.py`、`orchestrator.py`、`app.py`、`SQLiteHermesStore` | Dispatch recovery 与 Hermes restart-reconcile 测试 | 进程重启后读取 Dispatch/Job 并完成 Reconcile | 已实现;测试通过 |
|
||||
| UR-020 OpenClaw Ed25519 命令合同 | ADR-009;模块契约第 11 节 | `active-runtime/app/aios_openclaw/contract.py`、`adapter.py`、`app.py` | `test_candidate_flow.py`、`test_provenance.py` | 真实 OpenClaw v2 合同联调、Key rotation、clock skew/expiry 负向验收 | 已实现;测试代码已升级;真实联调待执行 |
|
||||
| UR-021 服务级权限隔离 | ADR-009;模块契约第 9 节 | `provenance_scoped.py`、`provenance_operations.py`、`app.py` | `test_provenance_grant_isolation.py` | Application 仅加载 Runtime/Evidence/Audit;Git/Deploy/Rollback 单独 Scope | 已实现;专项测试已提交;配置验收待执行 |
|
||||
| UR-022 CLI 单 Grant 授权 | ADR-009;模块契约第 9 节 | `provenance_cli.py` | `test_provenance_grant_isolation.py` 提供 Writer/Grant 基础验证;CLI 端到端待补执行证据 | `authorize --path ... --grant-id ...`;旧批量 Grant 参数应不存在 | 已实现;CLI 端到端验收待执行 |
|
||||
|
||||
## 3. API 追踪
|
||||
## 3. P0 加固项追踪
|
||||
|
||||
| API | 需求 | 实现 | 测试状态 |
|
||||
|---|---|---|---|
|
||||
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
|
||||
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
|
||||
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
|
||||
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
|
||||
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
|
||||
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
|
||||
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
|
||||
| P0 | 风险 | 实现 | 测试/证据 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| P0-1 公共生命周期旁路 | 客户端直接推进 RUNNING/COMPLETED/FAILED | `gateway.py` 公共入口拒绝;`orchestrator.py` 权威推进 | API 负向 Acceptance | 代码完成;待执行 |
|
||||
| P0-2 Dispatch 持久化 | 重启丢失 Runtime 绑定 | `dispatch_store.py` SQLite Repository;`app.py` 接线 | Dispatch restart recovery 测试 | 代码/测试已提交;待执行 |
|
||||
| P0-3 Result 严格绑定 | 伪造或错配 Runtime Result 更新 Task | `orchestrator.py` 校验 Runtime Execution、Tenant、Task、Trace、Principal、Provenance、Run | Runtime 正向恢复测试;负向 Acceptance | 代码完成;完整验收待执行 |
|
||||
| P0-4 OpenClaw Ed25519 | HMAC 共享密钥和合同不一致 | `aios_openclaw/contract.py` v2 Ed25519;安全文件读取 | 候选 Flow 测试;真实 Runtime 联调 | 代码/测试已提交;联调待执行 |
|
||||
| P0-5 最小权限 Grant | 单凭证拥有全部生产路径 | Workflow + Child Grant + Scoped Writer + 单 Grant CLI | Grant isolation 专项测试 | 代码/测试已提交;待执行 |
|
||||
|
||||
## 4. 运行时契约追踪
|
||||
## 4. Acceptance 证据要求
|
||||
|
||||
| 契约 | 设计 | 代码 | 测试 |
|
||||
|---|---|---|---|
|
||||
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
|
||||
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
|
||||
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
|
||||
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
|
||||
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
|
||||
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
|
||||
完整 Acceptance 至少应保存:
|
||||
|
||||
## 5. 部署追踪
|
||||
1. 测试命令、开始/结束时间、退出码和完整日志。
|
||||
2. `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` 的实际结果。
|
||||
3. 文档—代码同步门禁实际结果。
|
||||
4. Secret scanner、依赖和容器/systemd 安全检查结果。
|
||||
5. OpenClaw Ed25519 v2 真实联调结果。
|
||||
6. Hermes 内部 Reconcile 正向与伪造上下文负向结果。
|
||||
7. Control Plane、Dispatch、Provenance/Grant 的重启恢复结果。
|
||||
8. 部署、Healthcheck、备份和回滚演练结果。
|
||||
9. 分支、Commit、Controller clean、远端 OID 同步证据。
|
||||
10. Owner 对 Merge、Tag 和生产部署的独立批准。
|
||||
|
||||
| 能力 | 文件 | 验收命令 |
|
||||
## 5. 当前发布门禁
|
||||
|
||||
| Gate | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
|
||||
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
|
||||
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
|
||||
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
|
||||
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
|
||||
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
|
||||
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
|
||||
|
||||
## 6. 文档覆盖追踪
|
||||
|
||||
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|
||||
|---|---|---|
|
||||
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
|
||||
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
|
||||
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
|
||||
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
|
||||
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
|
||||
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
|
||||
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
|
||||
|
||||
## 7. 当前未完成验证项
|
||||
|
||||
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
|
||||
|
||||
1. 精确 Candidate commit 的测试执行结果;
|
||||
2. HTTP API 与 Web Console 冒烟测试;
|
||||
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
|
||||
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
|
||||
5. 多实例 SQLite/并发容量边界;
|
||||
6. systemd、Docker、Compose、restart 和 rollback 演练;
|
||||
7. local/Gitea Candidate OID 一致证明;
|
||||
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
|
||||
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
|
||||
|
||||
## 8. 更新要求
|
||||
|
||||
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||
| 实现 | 完成 | 五项 P0 代码与配置已提交当前加固分支 |
|
||||
| 测试代码 | 完成 | 回归、Dispatch recovery、Grant isolation 测试已提交 |
|
||||
| 测试执行 | 未完成 | 当前没有可验证的实际运行日志和退出码 |
|
||||
| 文档同步 | 已提交 | 需求、契约、ADR、追踪矩阵已更新;门禁尚未执行 |
|
||||
| Acceptance | 未完成 | `run-all.sh` 尚无本轮实际执行证据 |
|
||||
| Gate A | 未批准 | 只能在 Acceptance 通过并完成独立审查后批准 |
|
||||
| Merge/Tag/生产部署 | 未批准 | 需要 AIOS Owner 明确批准 |
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# AIOS V2R1C2 统一门户与可迁移执行器补充契约
|
||||
|
||||
- 文档版本:`V2R1C2-candidate`
|
||||
- 状态:实现与测试资产已提交;在线 Acceptance、Owner 审批与生产发布未完成
|
||||
- 适用范围:统一角色门户、External Task Truth、Executor Provider、Execution Plan、通知回执、候选部署与 Release Gate
|
||||
- 关联决策:`ADR-011`
|
||||
|
||||
## 1. 新增用户需求
|
||||
|
||||
### UR-023 统一角色感知门户
|
||||
|
||||
AIOS 必须使用同一 Web Portal 服务平台管理员、租户管理员、操作员、审批人、审计员、Runtime、发布经理和只读用户。服务端能力矩阵是唯一授权依据,前端隐藏按钮不得替代服务端授权。
|
||||
|
||||
验收:
|
||||
|
||||
- 门户至少包含总览、租户与 Agent、任务、执行中心、授权、Runtime、Evidence、Audit、GitOps/Release。
|
||||
- `GET /api/v2/me/capabilities` 返回可信身份解析后的角色、能力和导航。
|
||||
- 所有写操作必须再次进行服务端 Capability 检查。
|
||||
- 浏览器不得直接调用 OpenClaw、Hermes、Codex 或未来执行器。
|
||||
|
||||
### UR-024 外部执行任务真相
|
||||
|
||||
任何由 OpenClaw、Hermes、Codex 或未来 Provider 执行的任务,必须进入同一持久 Execution Truth Registry。任务文件、进程存在、日志字符串或退出码不能单独证明任务已经执行。
|
||||
|
||||
统一生命周期:
|
||||
|
||||
```text
|
||||
DECLARED -> REGISTERED -> CLAIMED -> RUNNING -> SUCCEEDED
|
||||
| -> FAILED
|
||||
+ timeout -> STALLED
|
||||
REGISTER deadline exceeded -> NOT_STARTED
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- 每次执行绑定 `task_id`、`executor_type`、`executor_id` 和 `executor_run_id`。
|
||||
- Claim、Start、Heartbeat、Success、Fail 均持久化事件。
|
||||
- Success 只有在所有步骤和 Required Evidence SHA256 完整时才允许。
|
||||
- 启动超时和心跳超时必须产生可查询的阻塞状态。
|
||||
|
||||
### UR-025 可迁移 Executor Provider
|
||||
|
||||
执行器类型不得由核心枚举固定。OpenClaw、Hermes 和 Codex 只是内置 Provider;未来执行器必须通过版本化 Provider Descriptor、Catalog 和 Adapter 接入,复用相同生命周期、Evidence、Notification 与 Gate。
|
||||
|
||||
验收:
|
||||
|
||||
- Provider Descriptor 包含 `executor_type`、`adapter_version`、`protocol_version`、`trust_roles`、`capabilities` 和配置 Schema。
|
||||
- Catalog 支持原子持久化、SHA256、防篡改导入导出和无 Secret 迁移包。
|
||||
- 新增 Provider 不修改 Truth Registry、Execution Plan 状态机、Portal 或 Release Gate。
|
||||
- API 的可信角色由 Provider Catalog 动态解析,不硬编码执行器名称。
|
||||
|
||||
### UR-026 多 Provider Execution Plan
|
||||
|
||||
发布任务必须可表达为任意 Provider 组成的 DAG。下游 Stage 在全部依赖 Gate 通过前保持 `WAITING_DEPENDENCY`,不得提前声明并消耗启动时限。
|
||||
|
||||
验收:
|
||||
|
||||
- Plan 使用 `aios.execution-plan.v2`,执行协议使用 `aios.executor.v1`。
|
||||
- Stage 包含 Provider、角色、步骤、依赖、Required Evidence、通知事件和超时。
|
||||
- Plan 创建时只声明根 Stage;依赖通过后原子激活下游 Stage。
|
||||
- 聚合 Release Gate 只有在全部 Required Stage 的 Truth、Evidence、Dependency 和 Notification Gate 通过时才允许放行。
|
||||
|
||||
### UR-027 Owner 主动通知与发布门禁
|
||||
|
||||
候选部署开始、成功和失败必须生成持久 Notification Outbox 事件。投递目标使用 Secret Reference,真实微信投递必须回写 Receipt SHA256。通知失败可幂等重试,但不得生成重复事件。
|
||||
|
||||
验收:
|
||||
|
||||
- `(task_id, event_type)` 唯一,同一 STARTED/SUCCEEDED/FAILED 事件只有一个 Notification ID。
|
||||
- `PENDING` 和 `FAILED` 均可重试;成功后原地更新为 `DELIVERED`。
|
||||
- Release Gate 要求配置的 STARTED 和 SUCCEEDED 通知均有 Receipt。
|
||||
- Git、日志、Portal 和任务资产不得存储微信账号、Token 或 Secret。
|
||||
|
||||
## 2. 补充模块契约
|
||||
|
||||
### 2.1 Executor Provider Catalog
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/executor_protocol.py`
|
||||
- `active-runtime/app/aios_v2/executor_catalog.py`
|
||||
- `active-runtime/app/aios_v2/official_executor_providers.py`
|
||||
|
||||
Catalog 只包含可迁移描述符,不包含 Endpoint Credential、Token、私钥或 Runtime Secret。导入迁移包必须同时校验 Package SHA256 与 Catalog SHA256。
|
||||
|
||||
### 2.2 External Execution Truth
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/task_execution_registry.py`
|
||||
- `active-runtime/app/aios_v2/external_task_control.py`
|
||||
- `active-runtime/app/aios_v2/external_task_query.py`
|
||||
|
||||
Truth Record 是外部执行状态的唯一事实源。`SUCCEEDED` 不等于 Release 可放行;仍需 Required Notification Receipt 和 Plan Dependency Gate。
|
||||
|
||||
### 2.3 Execution Plan
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/execution_plan.py`
|
||||
- `active-runtime/app/aios_v2/executor_worker.py`
|
||||
- `tools/aios_execution_plan_cli.py`
|
||||
|
||||
Plan 是 Stage 定义的唯一事实源。不得再复制维护三个独立 Task Manifest,否则会产生 Plan/Task 漂移并可能提前启动下游计时。
|
||||
|
||||
### 2.4 Portal/API
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_web/capabilities.py`
|
||||
- `active-runtime/app/aios_web/application.py`
|
||||
- `active-runtime/app/aios_web/release_application.py`
|
||||
- `active-runtime/app/aios_web/external_task_api.py`
|
||||
- `active-runtime/app/aios_web/execution_plan_api.py`
|
||||
- `active-runtime/app/aios_web/console.py`
|
||||
|
||||
核心新增 API:
|
||||
|
||||
```text
|
||||
GET /api/v2/portal/execution
|
||||
GET /api/v2/portal/external-tasks
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/discover?executor={provider}
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/snapshot?executor={provider}
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/gate?executor={provider}
|
||||
GET /api/v2/internal/external-tasks/discover?executor={provider}
|
||||
POST /api/v2/internal/external-tasks/{task_id}/{register|claim|start|heartbeat|succeed|fail}
|
||||
GET /api/v2/internal/external-tasks/notifications?executor={provider}
|
||||
POST /api/v2/internal/external-tasks/notifications/{notification_id}/ack
|
||||
```
|
||||
|
||||
内部 API 只接受可信代理注入身份。Provider Trust Role、Task Manifest Executor 与 Principal/Executor ID 必须一致。
|
||||
|
||||
### 2.5 Candidate Deployment
|
||||
|
||||
候选服务:
|
||||
|
||||
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.service`
|
||||
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.env.example`
|
||||
|
||||
约束:
|
||||
|
||||
- V2R1C1 生产继续监听 `127.0.0.1:18920`。
|
||||
- V2R1C2 Candidate 只监听 `127.0.0.1:18921`。
|
||||
- Candidate 使用 `/var/lib/aios-v2r1c2-portal`,不得复用生产状态目录。
|
||||
- `AIOS_RELEASE_SOURCE_OID` 必须解析为精确 40 位 Git OID,否则拒绝启动。
|
||||
- Portal 不直接创建 Tag、修改 `main` 或部署生产。
|
||||
|
||||
## 3. V2R1C2 发布计划
|
||||
|
||||
权威计划:
|
||||
|
||||
- `plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json`
|
||||
|
||||
DAG:
|
||||
|
||||
```text
|
||||
openclaw-candidate-deploy
|
||||
|
|
||||
v
|
||||
hermes-runtime-validation
|
||||
|
|
||||
v
|
||||
codex-release-review
|
||||
|
|
||||
v
|
||||
AIOS Owner approval -> tag/release request
|
||||
```
|
||||
|
||||
OpenClaw 负责隔离候选部署、离线/在线 Acceptance、Role/Tenant 负向测试、重启与回滚演练;Hermes 负责 Queue/Lease/Worker/恢复验证;Codex 负责源代码、证据、安全边界、文档追踪和发布建议审查。所有 Provider 使用同一 Truth Registry。
|
||||
|
||||
## 4. 需求追踪矩阵
|
||||
|
||||
| 需求 | 决策/契约 | 实现 | 测试/验收 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| UR-023 统一门户 | ADR-011;本文 2.4 | `aios_web/*` | `tests/web/test_portable_executor_release.py`;在线 Portal Acceptance | 已实现;测试资产已提交;在线未执行 |
|
||||
| UR-024 执行真相 | ADR-011;本文 2.2 | `task_execution_registry.py`、`external_task_control.py` | Truth/Evidence/Notification tests;CLI Gate | 已实现;实际执行证据待 OpenClaw/Hermes/Codex |
|
||||
| UR-025 Provider 可迁移 | ADR-011;本文 2.1 | `executor_protocol.py`、`executor_catalog.py`、`official_executor_providers.py` | Catalog migration/tamper/dynamic Provider tests | 已实现;测试资产已提交 |
|
||||
| UR-026 Execution Plan DAG | ADR-011;本文 2.3 | `execution_plan.py`、`executor_worker.py` | 下游延迟激活、聚合 Gate tests | 已实现;在线 DAG 未执行 |
|
||||
| UR-027 主动通知 | ADR-011;本文 2.2 | Notification Outbox、Worker retry | 首投失败、幂等重试、Receipt Gate test | 已实现;真实微信投递未执行 |
|
||||
|
||||
## 5. Acceptance 与证据
|
||||
|
||||
离线/在线验收入口:
|
||||
|
||||
```text
|
||||
python3 tools/aios_v2r1c2_acceptance.py \
|
||||
--source-oid <exact-40-character-oid> \
|
||||
[--online] \
|
||||
--evidence-output artifacts/v2r1c2/acceptance-evidence.json
|
||||
```
|
||||
|
||||
专项测试:
|
||||
|
||||
- `tests/web/test_portable_executor_release.py`
|
||||
- `tests/aios_v2_candidate/`
|
||||
|
||||
机器证据至少包含:命令、时间、退出码、输出 SHA256、HTTP 响应 SHA256、Source OID、Plan SHA256、Catalog SHA256、Service/Env SHA256、回滚结果和微信 Receipt SHA256。
|
||||
|
||||
## 6. 当前门禁状态
|
||||
|
||||
| Gate | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 代码与部署资产 | 已提交 | 当前候选分支包含实现、Plan、systemd、CLI 和测试资产 |
|
||||
| 测试执行 | 未验证 | 当前 GitOps 工具未执行 Python 测试,禁止声称通过 |
|
||||
| OpenClaw Candidate Stage | `NOT_STARTED` | 必须由目标环境实际 Discover/Register/Claim/Start |
|
||||
| Hermes Runtime Stage | `WAITING_DEPENDENCY` | OpenClaw Gate 通过后才激活 |
|
||||
| Codex Review Stage | `WAITING_DEPENDENCY` | OpenClaw 与 Hermes Gate 通过后才激活 |
|
||||
| 微信通知 | 未投递 | 需要目标环境 Sender 和 Receipt 回写 |
|
||||
| Tag/生产发布 | 未批准 | 需要 Plan Gate 通过和 AIOS Owner 明确批准 |
|
||||
|
||||
测试文件、Acceptance 脚本、Task YAML、systemd 文件或日志文本的存在都不等于实际 Gate 已通过。
|
||||
@@ -0,0 +1,51 @@
|
||||
# V2R1C2 Owner Provenance 与微信接管迁移
|
||||
|
||||
## 边界
|
||||
|
||||
本迁移不关闭或降低 `aios-tenant-boundary`。普通、已识别的消息可以进入只读对话;
|
||||
只有服务端确认的 Owner 才能为 `aios_protected_dispatch` 自动签发
|
||||
`aios.owner-provenance/v2`。未知、阻止名单和受保护操作的非 Owner 请求继续 fail closed。
|
||||
|
||||
客户端不得提交可信身份 Header、私钥、签名或 Provenance Token。插件从 OpenClaw
|
||||
可信 hook 上下文取得 `principal_id`、`agent_id`、`run_id` 和 execution path,并对
|
||||
精确工具参数计算 SHA256。服务端签发记录绑定:
|
||||
|
||||
- `tenant_id`
|
||||
- `principal_id`
|
||||
- `task_id`
|
||||
- `agent_id`
|
||||
- `run_id`
|
||||
- `execution_path`
|
||||
- `request_digest`
|
||||
- `issued_at` / `expires_at`
|
||||
- `key_id` 与覆盖全部字段的 Ed25519 signature
|
||||
|
||||
参数在 hook 与 tool execute 之间改变、错误身份绑定、过期、撤销或单次 permit 重放
|
||||
都会被拒绝。
|
||||
|
||||
## 配置迁移
|
||||
|
||||
私钥仍位于仓库外。部署系统只写入路径引用,并验证目标文件存在、为普通文件且权限
|
||||
不向 group/other 开放。需要在 Candidate 专用配置中增加:
|
||||
|
||||
```text
|
||||
provenancePrivateKeyPath=<outside-git path>
|
||||
provenancePublicKeyPath=<outside-git path>
|
||||
provenanceStoreDir=/var/lib/aios-v2r1c2-portal/provenance
|
||||
provenanceKeyId=<non-secret key id>
|
||||
tenantId=<candidate tenant>
|
||||
protectedDispatchProvider=chatgpt-supervisor
|
||||
protectedDispatchTransport=mock|sandbox
|
||||
ownerProvenanceTtlMs<=300000
|
||||
```
|
||||
|
||||
`mockFailFirstDelivery` 只允许在明确的模拟验收配置中启用。模拟输出必须保留:
|
||||
`simulation=true`、`transport=mock|sandbox`、`real_user_impersonated=false`。
|
||||
|
||||
## 部署与回滚
|
||||
|
||||
1. 从精确 Candidate OID 构建插件与 Portal;不得从 live 目录回写源码。
|
||||
2. 备份 Candidate 插件、配置和 unit;不修改 18920 服务或状态目录。
|
||||
3. 在 18921 完成正向链、负向矩阵、mock/sandbox 微信、restart recovery。
|
||||
4. 回滚只停止 `aios-v2r1c2-portal.service` 并恢复 Candidate 备份;随后重新验证 18920。
|
||||
5. 未经 Owner 明确批准,不创建 Tag、不修改 `main`、不替换生产。
|
||||
@@ -0,0 +1,104 @@
|
||||
# AIOS V2R1 Hermes 生命周期最终验证报告
|
||||
|
||||
- 报告版本:`V2R1-hermes-lifecycle-20260721.3`
|
||||
- 日期:`2026-07-21`
|
||||
- 分支:`patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||
- 固定验证 OID:`95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||
- 总体状态:`PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||
- 发布决策:`STOP_PREPRODUCTION`
|
||||
- 生产发布:`NOT_APPROVED`
|
||||
|
||||
## 1. 执行结论
|
||||
|
||||
`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 的 Hermes 功能与证据子条件已在固定 OID 上全部通过。候选实现复用现有 Hermes Adapter、Queue、Lease、Task 与 Orchestrator 状态机,新增持久 SQLite Store 和默认关闭的受保护内部生命周期接口。Hermes 完整生命周期 `17/17`、候选测试 `49/49`、Gate A、Gate B `10/10`、离线 Acceptance `8/0/1`、真实 OpenClaw HTTP、重启恢复、双向回滚和最终健康检查均通过。
|
||||
|
||||
但 Owner 指令规定只有全部 Acceptance 条件同时满足时才允许正式解除该 Blocker。由于“所有 Worktree clean”失败,本报告不得把它标记为正式 `RESOLVED`;准确状态是 `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`。
|
||||
|
||||
最终发布门禁仍失败:权威 Bare Repo 登记的 88 个 worktree 中有 10 个脏 worktree。候选 worktree 和活动 Controller worktree 均 clean,且无 locked/prunable;但 Owner 的验收条件要求所有 worktree clean,因此不得准备 RC、不得发起 Tag Approval Request、不得进入预生产评审。
|
||||
|
||||
## 2. 关键实现
|
||||
|
||||
- `SQLiteHermesStore` 持久保存 Job、Result、Idempotency 与 Worker Heartbeat。
|
||||
- 现有 `HermesAdapter` 保持唯一 Queue/Lease 状态机,支持指定 Job claim、租约到期 requeue、重启恢复与严格终态冲突。
|
||||
- 生命周期身份绑定覆盖 tenant、task、trace、principal、provenance、run、dispatch、job、cell、profile。
|
||||
- start/heartbeat/complete/fail 强制 Lease Owner;complete/fail/cancel 通过 `ExecutionOrchestrator.reconcile_hermes` 更新 Task 与 Dispatch。
|
||||
- cancel 仅允许同时具备 `runtime` 与 `platform_admin` 的 AIOS Control Plane,要求 `cancellation_source=aios-control-plane`,并拒绝携带 `worker_id`,Worker 只能观察取消。
|
||||
- 所有成功生命周期操作写入 Audit/Event/Execution Evidence;接口由 `AIOS_HERMES_LIFECYCLE_API_ENABLED` 控制,默认关闭。
|
||||
|
||||
## 3. 固定 OID 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| Python syntax/import | PASS |
|
||||
| 全部 candidate tests | `49/49` |
|
||||
| Grant isolation | `11/11` |
|
||||
| Dispatch recovery | `1/1` |
|
||||
| Hermes lifecycle | `17/17` |
|
||||
| Docs code gate / Gate A | `0 errors / 0 warnings` |
|
||||
| Secret scan | `547 tracked files / 0 violations` |
|
||||
| SQLite quick_check | `2/2 ok` |
|
||||
| Gate B | `10/10` |
|
||||
| Offline Acceptance | `8 passed / 0 failed / 1 expected warning` |
|
||||
| OpenClaw real HTTP | `COMPLETED` |
|
||||
| Hermes full live rerun | `17/17` |
|
||||
| Restart recovery | `2/2` |
|
||||
| Previous → Current rollback/forward | PASS |
|
||||
| State preservation | `STATE_PRESERVED_EXACTLY=PASS` |
|
||||
| Corrected final health (`16R`) | PASS,三服务 active |
|
||||
|
||||
原 `16.log` 由非服务用户执行健康脚本,打印 state-dir 权限失败后被外层脚本错误覆盖为退出码 0,因此不作为通过证据。纠错后的 `16R.log` 使用受控 `sudo env`,子 shell 保留失败码,所有健康项实际 PASS 且 `EXIT_CODE=0`。原文件保留用于审计。
|
||||
|
||||
回滚日志中的短暂 connection-refused 发生于 systemd 重启窗口内,受 `AIOS_V2_ROLLBACK_HEALTH_RETRIES=30` 与 1 秒间隔限制;前后两次均在界限内达到健康、输出 `ROLLBACK_COMPLETE`,并完成状态文件精确哈希对账。
|
||||
|
||||
## 4. Evidence
|
||||
|
||||
- 根目录:`/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||
- `steps.tsv` SHA256:`b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf`
|
||||
- `final-live-gate.log` SHA256:`82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa`
|
||||
- `16R.log` SHA256:`641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf`
|
||||
- `15.log` SHA256:`b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e`
|
||||
- `13.log` SHA256:`b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710`
|
||||
- `12R2.log` SHA256:`0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67`
|
||||
|
||||
证据只记录身份、状态、命令、时间、退出码与 Hash,不记录 Secret 值。
|
||||
|
||||
## 5. Git 与运行态门禁
|
||||
|
||||
| 门禁 | 当前值 | 结果 |
|
||||
|---|---|---|
|
||||
| Bare/Gitea/Candidate OID | 全部 `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4` | PASS |
|
||||
| Candidate worktree | clean | PASS |
|
||||
| Active Controller | `6188da26453505dacde9d6d298191437164c8d27`,detached、clean | PASS |
|
||||
| Locked worktree | `0` | PASS |
|
||||
| Prunable worktree | `0` | PASS |
|
||||
| 全局 worktree clean | `10 dirty / 88 total` | FAIL |
|
||||
| 当前隔离 Release | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 已恢复 |
|
||||
| AIOS/OpenClaw/Controller services | active | PASS |
|
||||
|
||||
10 个脏 worktree 均不属于本任务允许清理的范围:
|
||||
|
||||
1. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715`(36)
|
||||
2. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715`(2)
|
||||
3. `chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8`(2)
|
||||
4. `codex`(56)
|
||||
5. `codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8`(6)
|
||||
6. `controller`(11)
|
||||
7. `openclaw`(5)
|
||||
8. `openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`(3)
|
||||
9. `openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715`(20)
|
||||
10. `openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8`(3)
|
||||
|
||||
## 6. Reviewer
|
||||
|
||||
独立 Spark Reviewer 首先发现 cancel 权限边界不够明确;该问题已在 `95d2e52` 修复并新增三条 HTTP 断言。纠错后 Reviewer 确认代码安全边界、16 步证据与有界回滚均 PASS。对发布门禁的最终布尔复核为:`DIRTY_WORKTREE_COUNT=10` 且 `GLOBAL_WORKTREE_CLEAN=false`,所以全局门禁 FAIL,不能进入预生产评审。
|
||||
|
||||
## 7. Blocker 与发布决策
|
||||
|
||||
- 功能与证据子条件通过、正式关闭受阻:`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
|
||||
- 直接阻断条件:`BLK-GLOBAL-WORKTREE-CLEANLINESS`
|
||||
- 状态:`BLOCKED_EVIDENCE_REQUIRED`
|
||||
- 决策:`STOP_PREPRODUCTION`
|
||||
|
||||
本轮未修改 `main`、未创建 Tag、未 Force Push、未切换生产流量、未部署新 RC、未输出 Secret。
|
||||
|
||||
下一步唯一允许动作:由各 worktree Owner 审查并提交、归档或可信清理上述 10 个脏 worktree,然后重新执行全局 Git 门禁。只有全局 dirty 数量降为 0 后,才允许准备 RC 与 Tag Approval Request。
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-007:AIOS V2 Run Provenance 可信执行来源控制
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-20
|
||||
- 影响范围:Runtime 执行、Evidence、GitOps、部署、回滚、审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
AIOS V2 在进入预生产阶段前,需要证明每一次高权限动作来源可信。仅依靠进程内 runId、sender 或任务字段无法证明:
|
||||
|
||||
- 谁授权了执行;
|
||||
- 执行是否属于当前任务;
|
||||
- Git、部署、回滚和 Evidence 写入是否经过批准;
|
||||
- 是否存在重放或伪造运行记录。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
引入 Run Provenance:
|
||||
|
||||
```text
|
||||
Owner / Control Plane
|
||||
|
|
||||
v
|
||||
Ed25519 Signed Provenance
|
||||
|
|
||||
v
|
||||
Persistent Provenance Store
|
||||
|
|
||||
v
|
||||
Controlled Writer
|
||||
|
|
||||
v
|
||||
Runtime / GitOps / Deploy / Evidence / Rollback
|
||||
```
|
||||
|
||||
## 3. 七类受保护路径
|
||||
|
||||
生产 Provenance 必须覆盖:
|
||||
|
||||
1. code_change
|
||||
2. git_operation
|
||||
3. deployment
|
||||
4. runtime_execute
|
||||
5. evidence_write
|
||||
6. rollback
|
||||
7. audit
|
||||
|
||||
任何未授权路径默认拒绝。
|
||||
|
||||
## 4. 安全规则
|
||||
|
||||
- 私钥只用于签发端。
|
||||
- Runtime 只持有公钥验证能力。
|
||||
- Provenance 使用 Ed25519 签名。
|
||||
- 状态持久保存 issued、verified、used、expired、revoked。
|
||||
- 相同 provenance/path/request_digest 不允许重复授权。
|
||||
- Secret 不进入 Git、日志和 Evidence。
|
||||
|
||||
## 5. 实现映射
|
||||
|
||||
代码:
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_v2/provenance.py
|
||||
active-runtime/app/aios_v2/provenance_git.py
|
||||
active-runtime/app/aios_v2/provenance_operations.py
|
||||
active-runtime/app/aios_v2/provenance_cli.py
|
||||
```
|
||||
|
||||
接入:
|
||||
|
||||
```text
|
||||
orchestrator.py
|
||||
execution_tracking.py
|
||||
app.py
|
||||
```
|
||||
|
||||
## 6. 验收要求
|
||||
|
||||
必须验证:
|
||||
|
||||
- 签名正确执行;
|
||||
- 篡改拒绝;
|
||||
- 过期拒绝;
|
||||
- revoke 拒绝;
|
||||
- replay 拒绝;
|
||||
- 无 Provenance Runtime Dispatch 拒绝;
|
||||
- 无 Evidence 授权写入拒绝;
|
||||
- 无 Git 授权操作拒绝;
|
||||
- 无 Deployment/Rollback 授权拒绝。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
若 Provenance 服务异常:
|
||||
|
||||
- 不允许降级绕过安全检查进入生产;
|
||||
- 候选环境可以在明确测试配置中关闭强制模式;
|
||||
- 生产模式必须保持 AIOS_REQUIRE_RUN_PROVENANCE=true。
|
||||
|
||||
## 8. 后续演进
|
||||
|
||||
生产阶段可进一步接入:
|
||||
|
||||
- HSM/KMS;
|
||||
- 外部不可变审计;
|
||||
- 多签审批;
|
||||
- 硬件密钥认证。
|
||||
@@ -0,0 +1,156 @@
|
||||
# ADR-009:Workflow Provenance 与最小权限子 Grant
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-21
|
||||
- 决策范围:AIOS V2R1 生产授权、Runtime 调度、Evidence、Git、部署、回滚与审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
早期候选实现使用一个 Run Provenance 同时承载身份绑定和七类生产操作权限。该模型可以证明一次执行来自哪个租户、任务、Agent 与 Principal,但同一凭证能够授权 Runtime、Evidence、Git、部署、回滚、审计和代码变更,违反最小权限原则。任何持有该凭证的进程都可能获得超出职责的能力。
|
||||
|
||||
同时,Runtime Result 回写、Git 写入和部署包装器需要可撤销、可过期、可重放防护且可持久恢复的独立授权。仅依赖进程角色或环境变量不足以形成可审计的生产信任链。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
采用两层 Provenance 模型:
|
||||
|
||||
1. **Workflow Provenance** 只绑定 `run_id`、`tenant_id`、`task_id`、`agent_id`、`principal_id`、签发者、TTL、Key ID、Nonce 和签名,不授予任何操作路径。
|
||||
2. **Provenance Grant** 由 Workflow Provenance 派生,每个 Grant 只绑定一个 `ExecutionPath`,拥有独立的 TTL、Nonce、状态、撤销和重放记录。
|
||||
3. Git Grant 还必须显式限定允许的 `GitOperation`,例如只允许 `READ_REF` 而不允许 `PUSH`。
|
||||
4. 子 Grant 的身份字段必须与父 Workflow 完全一致,且子 Grant 的有效期不得超过父 Workflow。
|
||||
5. 父 Workflow 被撤销或过期后,其全部子 Grant 自动失效。
|
||||
6. 授权使用 `(grant_id, execution_path, git_operation, request_digest)` 建立唯一使用记录,重复请求默认拒绝。
|
||||
|
||||
## 3. Grant 类型
|
||||
|
||||
| GrantType | 唯一允许路径 |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
Grant 类型与路径不一致时,构造阶段即拒绝。非 Git Grant 不得包含 Git 操作;Git Grant 未声明具体操作时不得签发。
|
||||
|
||||
## 4. 服务级 Scope
|
||||
|
||||
生产进程不得加载全部 Grant。`ScopedLeastPrivilegeProvenanceWriter` 只接受当前服务声明的路径和对应 Grant ID,多余 Grant 映射会导致启动失败。
|
||||
|
||||
默认 AIOS Application Scope 仅包含:
|
||||
|
||||
- `runtime_execute`
|
||||
- `evidence_write`
|
||||
- `audit`
|
||||
|
||||
Git、部署、回滚和代码变更必须由独立进程或包装器显式声明自己的单一路径 Scope。运维 CLI 的一次授权命令只接受一个 `--grant-id`,并要求它与 `--path` 一致。
|
||||
|
||||
## 5. Runtime 与 Dispatch 绑定
|
||||
|
||||
Runtime 执行还必须经过持久化 `DispatchRecord`。记录至少绑定:
|
||||
|
||||
- `dispatch_id`
|
||||
- `tenant_id`
|
||||
- `task_id`
|
||||
- `agent_id`
|
||||
- `principal_id`
|
||||
- `runtime`
|
||||
- `runtime_execution_id`
|
||||
- `trace_id`
|
||||
- `provenance_id`
|
||||
- `run_id`
|
||||
- `authorization_id`
|
||||
|
||||
OpenClaw/Hermes 结果只有在上述上下文完全匹配时才能由 Orchestrator 更新 Task。公开 Task API 不允许直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`;Hermes 结果只通过内部 Runtime Reconcile 入口处理。
|
||||
|
||||
## 6. 密钥与签名
|
||||
|
||||
Workflow Provenance、子 Grant 和 OpenClaw Command 均采用 Ed25519。生产应用只读取公钥;私钥仅存在于受保护的签发端或 systemd credential。Git、日志、环境模板和审计中不得出现私钥内容。
|
||||
|
||||
Key ID 用于选择受信任密钥,不包含密钥材料。公钥 Fingerprint 使用恒定时间比较。OpenClaw Command 合同版本为 `aios.openclaw.command.v2`。
|
||||
|
||||
## 7. 备选方案
|
||||
|
||||
### 7.1 单一全权限 Provenance
|
||||
|
||||
拒绝。实现简单,但任何服务泄漏都可能获得全部生产能力,无法独立撤销 Runtime、Git 或部署权限。
|
||||
|
||||
### 7.2 仅依赖 RBAC
|
||||
|
||||
拒绝。RBAC 可以限制 API 调用者,但不能把具体 Run、Task、Agent、请求摘要和 Runtime Result 绑定到一次可验证授权。
|
||||
|
||||
### 7.3 每个操作重新签发完整 Workflow
|
||||
|
||||
拒绝。会复制身份数据和生命周期状态,难以统一撤销整次工作流,也增加签发和审计复杂度。
|
||||
|
||||
## 8. 正面影响
|
||||
|
||||
- 服务与操作权限按职责隔离。
|
||||
- Runtime、Evidence、Git、部署、回滚和审计可以独立撤销。
|
||||
- 父 Workflow 可作为整次 Run 的紧急停止开关。
|
||||
- SQLite 持久化支持重启后的授权与 Dispatch 恢复。
|
||||
- Receipt 明确记录 Grant、Scope、请求摘要和授权时间。
|
||||
- Git Index 只有在 Grant 授权完成后才允许修改。
|
||||
|
||||
## 9. 负面影响与风险
|
||||
|
||||
- 签发和部署配置比单凭证模式复杂。
|
||||
- 每个生产服务必须配置正确的 Grant ID。
|
||||
- Grant 状态与 Dispatch 状态需要备份、迁移和容量治理。
|
||||
- 当前候选实现使用 SQLite;高可用生产部署仍需外部持久化和并发容量证据。
|
||||
- OpenClaw callback 目前只作为非权威审计事件;状态变化仍必须经过 Orchestrator 严格 Reconcile。
|
||||
|
||||
## 10. 迁移与回滚
|
||||
|
||||
迁移步骤:
|
||||
|
||||
1. 签发无操作权限的 Workflow Provenance。
|
||||
2. 按所需路径签发独立子 Grant。
|
||||
3. 为每个服务配置其最小 Grant ID。
|
||||
4. 启用 `AIOS_REQUIRE_RUN_PROVENANCE=true`。
|
||||
5. 验证 Scope 拒绝、撤销、过期、重放和重启恢复测试。
|
||||
6. 完成预发布 Acceptance 后,禁止旧全权限 `issue` 命令进入生产流程。
|
||||
|
||||
回滚仅允许回到前一个已验证候选版本,不得回滚为生产全权限 Provenance。状态恢复必须保留父 Workflow、Grant 使用记录、Dispatch 和 Receipt,防止旧授权被再次使用。
|
||||
|
||||
## 11. 合规检查
|
||||
|
||||
必须满足:
|
||||
|
||||
- 父 Workflow 无操作路径。
|
||||
- 子 Grant 只包含一个路径。
|
||||
- 生产服务只加载职责所需 Grant。
|
||||
- 上下文不匹配、签名错误、过期、撤销和重放全部 fail closed。
|
||||
- Runtime Result 必须匹配持久 Dispatch。
|
||||
- 测试与文档在同一分支提交。
|
||||
- 正式 Gate A 结论必须基于实际执行 Acceptance,不得仅凭测试文件存在声明通过。
|
||||
|
||||
## 12. 受影响资产
|
||||
|
||||
代码:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance.py`
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
- `active-runtime/app/aios_v2/provenance_operations.py`
|
||||
- `active-runtime/app/aios_v2/provenance_cli.py`
|
||||
- `active-runtime/app/aios_v2/dispatch_store.py`
|
||||
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||
- `active-runtime/app/aios_v2/provenance_git.py`
|
||||
- `active-runtime/app/aios_openclaw/contract.py`
|
||||
|
||||
测试:
|
||||
|
||||
- `tests/aios_v2_candidate/test_provenance.py`
|
||||
- `tests/aios_v2_candidate/test_provenance_grant_isolation.py`
|
||||
- `tests/aios_v2_candidate/test_candidate_flow.py`
|
||||
|
||||
部署与文档:
|
||||
|
||||
- `deploy/aios-v2/env/aios-v2.env.example`
|
||||
- `docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||
- `docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||
- `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`
|
||||
@@ -0,0 +1,80 @@
|
||||
# ADR-010:Hermes 在线生命周期使用默认关闭的内部 Runtime API
|
||||
|
||||
- 状态:Accepted for Release Candidate
|
||||
- 日期:2026-07-21
|
||||
- 范围:AIOS V2R1 Hermes Worker Lifecycle Acceptance
|
||||
|
||||
## 决策
|
||||
|
||||
使用现有 `HermesAdapter` Queue/Lease 状态机,增加 SQLite 持久 Store,并通过默认关闭的
|
||||
内部 Runtime API 执行 Claim、Start、Heartbeat、Complete、Fail、Cancel 与 Status。
|
||||
不建立第二套 Queue、Lease 或 Task 状态机。
|
||||
|
||||
内部 API 只接受可信代理注入且具有 `runtime`/`platform_admin` 角色的身份。请求必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
Complete、Fail、Cancel 只能经 `ExecutionOrchestrator.reconcile_hermes` 更新权威 Task。
|
||||
|
||||
## 背景
|
||||
|
||||
原 Candidate 已具有进程内 Hermes Claim/Lease/Worker 能力及持久 Dispatch,但在线服务
|
||||
只暴露 enqueue/reconcile,且 Hermes Job/Lease 本身在重启后丢失,不能形成完整在线证据。
|
||||
|
||||
## 原因
|
||||
|
||||
直接验收驱动会形成 Adapter 之外的额外变更入口,难以证明 HTTP 身份、Dispatch 与
|
||||
Orchestrator 边界。受保护的内部 API 可以在真实 localhost 服务、真实 SQLite Store 和
|
||||
真实 HTTP 解析路径上验证完整生命周期,同时在生产通过配置保持关闭。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- 受信验收驱动直接导入 Adapter:改动较小,但不能证明内部 HTTP 身份与请求解析边界,拒绝。
|
||||
- 新建独立 Queue/Lease 服务:会产生第二状态机和双写一致性问题,禁止。
|
||||
- 开放公共 Worker API:扩大攻击面并允许浏览器/Operator 触发 Runtime 状态,禁止。
|
||||
|
||||
## 安全不变量
|
||||
|
||||
1. 只有 APPROVED Task 可由 Orchestrator 创建 Hermes Job。
|
||||
2. Dispatch 在 Runtime 副作用前持久化。
|
||||
3. Worker 只能操作精确绑定且由其持有 Lease 的 Job。
|
||||
4. Runtime 结果必须通过完整身份元组与持久 Dispatch 校验。
|
||||
5. 只有 Orchestrator 能把 Task/Dispatch 推进到 Runtime 终态。
|
||||
6. 生命周期写入同步生成 Audit/Event 与 Execution Evidence。
|
||||
7. 相同终态重放幂等,不同终态或不同内容 fail closed。
|
||||
|
||||
## 恢复语义
|
||||
|
||||
`SQLiteHermesStore` 持久保存 Job、Result、Lease、幂等键与 Worker heartbeat。服务重启
|
||||
加载同一状态;过期 Lease 被持久重排为 QUEUED,清理旧 Owner 并递增 revision。新 Worker
|
||||
可重领,旧 Worker 的 Start/Heartbeat/Result 被拒绝。
|
||||
|
||||
## 影响与风险
|
||||
|
||||
- 正面:同一 Adapter 状态机获得持久恢复、真实 HTTP 验收、严格身份绑定和可审计证据。
|
||||
- 负面:SQLite Snapshot 每次生命周期变更都执行同步事务,需在预生产观察锁等待与吞吐。
|
||||
- 风险:可信代理头是身份边界;因此接口必须保持 localhost/内部路由、默认关闭,并由部署
|
||||
配置保证外部请求不能自行注入身份头。
|
||||
|
||||
## 迁移与回滚
|
||||
|
||||
新 Store 文件为新增的 `hermes.sqlite3`,不修改既有 Control Plane/Dispatch schema。回滚到
|
||||
Previous OID 时保留该文件但旧版本不读取;不得用旧备份覆盖新业务状态。重新升级时可继续
|
||||
加载。接口回滚只需关闭环境开关并恢复 Previous Release OID。
|
||||
|
||||
## 合规检查
|
||||
|
||||
- 不修改 main,不创建 Tag,不绕过 Provenance/Grant。
|
||||
- 生产默认关闭生命周期验收入口。
|
||||
- Secret 不进入请求、Audit、Evidence 或测试数据。
|
||||
- Result 由 Orchestrator 与持久 Dispatch 校验,负向场景 fail closed。
|
||||
|
||||
## 受影响资产
|
||||
|
||||
- 代码:`aios_hermes/adapter.py`、`aios_v2/app.py`、`orchestrator.py`、`gateway.py`。
|
||||
- 测试:`test_hermes_live_lifecycle.py` 及 Candidate/Provenance 回归。
|
||||
- 文档:用户需求、模块合同、追踪矩阵、Gate A 报告与本 ADR。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/aios_v2_candidate/test_hermes_live_lifecycle.py` 通过真实 localhost HTTP Server 与
|
||||
临时 SQLite 状态目录覆盖 17 项正向、负向、Lease、幂等和重启场景。最终发布结论仍必须
|
||||
以提交后的 Gate、在线回归、Secret Audit、SQLite 检查、回滚与独立 Reviewer 证据为准。
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIOS V2 架构决策记录索引
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 适用范围:影响多个模块、长期演进、兼容性、安全、数据、部署或交付流程的关键架构决策
|
||||
|
||||
## 1. 架构决策记录列表
|
||||
@@ -13,15 +13,27 @@
|
||||
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离采用默认拒绝模型 | 已采纳 |
|
||||
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 | 已采纳 |
|
||||
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot | 已采纳 |
|
||||
| ADR-007 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| ADR-008 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| [ADR-009](ADR-009-provenance-least-privilege.md) | Workflow Provenance、最小权限子 Grant 与服务级 Scope | 已采纳 |
|
||||
| [ADR-010](ADR-010-hermes-live-lifecycle.md) | Hermes 在线生命周期使用默认关闭的内部 Runtime API | RC 已采纳;待 Owner 发布批准 |
|
||||
|
||||
## 2. 状态定义
|
||||
## 2. 编号规则
|
||||
|
||||
- ADR 编号一经发布不得重用。
|
||||
- 编号缺口可以保留,不能通过伪造历史文件填补。
|
||||
- 新决策使用下一个未占用编号,并在同一分支更新本索引、模块契约和追踪矩阵。
|
||||
- ADR-007 与 ADR-008 在当前仓库没有可验证历史文件,因此明确标记为未分配。
|
||||
|
||||
## 3. 状态定义
|
||||
|
||||
- **已提出**:决策已提出,但尚未被正式采纳。
|
||||
- **已采纳**:当前有效,所有新实现必须遵守。
|
||||
- **已替代**:已被新的 ADR 替代,保留用于历史追踪。
|
||||
- **已废弃**:保留历史,但禁止新实现继续采用。
|
||||
- **未分配**:该编号当前没有 ADR 文件,不代表存在隐含历史决策。
|
||||
|
||||
## 3. 必须新增或更新 ADR 的场景
|
||||
## 4. 必须新增或更新 ADR 的场景
|
||||
|
||||
以下变化必须新增或更新 ADR:
|
||||
|
||||
@@ -32,9 +44,10 @@
|
||||
5. GitOps、发布、回滚或文档门禁变化;
|
||||
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施;
|
||||
7. 引入新的权威事实源、任务生命周期或租户状态;
|
||||
8. 生产安全模型、身份代理或 Secret 管理方式变化。
|
||||
8. 生产安全模型、身份代理、Run Provenance、Grant 或 Secret 管理方式变化;
|
||||
9. Runtime Result、Dispatch 或服务 Scope 信任边界变化。
|
||||
|
||||
## 4. ADR 内容要求
|
||||
## 5. ADR 内容要求
|
||||
|
||||
每份 ADR 必须包含:
|
||||
|
||||
@@ -48,20 +61,22 @@
|
||||
- 合规检查;
|
||||
- 受影响的代码、测试和文档。
|
||||
|
||||
## 5. ADR 变更联动
|
||||
## 6. ADR 变更联动
|
||||
|
||||
ADR 新增、替代或废弃时,必须同步检查并更新:
|
||||
|
||||
- `01-USER-REQUIREMENTS.md`;
|
||||
- `02-TARGET-ARCHITECTURE.md`;
|
||||
- `03-MIGRATION-AND-DELIVERY.md`;
|
||||
- `04-MODULE-CONTRACTS.md`;
|
||||
- `05-TRACEABILITY-MATRIX.md`;
|
||||
- 相关代码、测试和部署资产;
|
||||
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md` 中的风险与发布结论。
|
||||
- 候选或 Gate 报告中的风险与发布结论。
|
||||
|
||||
## 6. 审查规则
|
||||
## 7. 审查规则
|
||||
|
||||
- OpenClaw 不得自行采纳改变控制面边界的 ADR;发现需求后必须升级给 Codex。
|
||||
- Codex 负责审查 ADR 与代码、测试和文档是否一致。
|
||||
- 涉及合并、Tag、生产部署或重大风险接受的 ADR,必须由 AIOS Owner 最终批准。
|
||||
- ADR 未完成审查前,相关变更不得标记为 `Test-verified` 或 `Production released`。
|
||||
- ADR、测试文件或 Acceptance 脚本存在,不等于 Gate 已通过;必须有实际执行日志、退出码和证据。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# AIOS V2R1C1 Release Notes
|
||||
|
||||
- VRC: `V2R1C1`
|
||||
- SemVer: `2.1.0-rc.1`
|
||||
- 固定产品基线: `fb739f6e8ff9c031d319221ace68dce157694086`
|
||||
- 当前状态: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
|
||||
- 发布决策: `STOP_PREPRODUCTION`
|
||||
|
||||
OpenClaw Command v2 Companion:
|
||||
|
||||
- 分支: `patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721`
|
||||
- 源 OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
|
||||
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
|
||||
- 插件版本: `2.1.0-rc.4`
|
||||
- 合同: `aios.openclaw.command.v2` / `Ed25519`
|
||||
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
|
||||
- 隔离验证: `AIOS -> OpenClaw V2 PASS`,正向与负向在线合同均通过。
|
||||
|
||||
旧 Release OID `80c4cbde03a82c30648a09b817bc2f03d5402936` 保留为原始 HTTP 401 失败诊断基线。Release OID `0778a31ec073e7c90003ef091f1d093c16bf22e5` 已实际完成 Gate A/B、文档同步、Secret Scan、Release Metadata、OpenClaw 第 13 步正负向、Hermes、重启恢复、隔离回滚/前滚和最终健康门禁,全部通过。
|
||||
|
||||
本次最终元数据闭环仍只修改五个允许的 Release 元数据文件;提交后形成的最终 OID 必须再次执行 Gate A/B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正负向复验。复验通过前 Tag 保持不存在,生产流量保持未切换。
|
||||
|
||||
本分支只包含发布元数据。Personal Weixin Portal Adapter、Spark 专有 Provisioning、Future Incubator、Worktree 并发实验和长期 Agent 工作目录均未进入发布图。
|
||||
|
||||
只有最终 Release OID 的全部复验和步骤 14–17 通过后,才允许创建不可变 `V2R1C1` Tag。
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safely patch Tencent Weixin sender identity and transient session retries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
PATCHES = {
|
||||
"src/messaging/inbound.ts": [
|
||||
(
|
||||
" From: from_user_id,\n To: from_user_id,",
|
||||
" From: from_user_id,\n SenderId: from_user_id,\n To: from_user_id,",
|
||||
),
|
||||
],
|
||||
"dist/src/messaging/inbound.js": [
|
||||
(
|
||||
" From: from_user_id,\n To: from_user_id,",
|
||||
" From: from_user_id,\n SenderId: from_user_id,\n To: from_user_id,",
|
||||
),
|
||||
],
|
||||
"src/monitor/monitor.ts": [
|
||||
(
|
||||
'import { processOneMessage } from "../messaging/process-message.js";',
|
||||
'import { processOneMessage } from "../messaging/process-message.js";\n'
|
||||
'import type { ProcessMessageDeps } from "../messaging/process-message.js";\n'
|
||||
'import type { WeixinMessage } from "../api/types.js";',
|
||||
),
|
||||
(
|
||||
"const RETRY_DELAY_MS = 2_000;\n",
|
||||
"const RETRY_DELAY_MS = 2_000;\n"
|
||||
"const REPLY_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const REPLY_SESSION_INIT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000] as const;\n",
|
||||
),
|
||||
(
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
"export function resolveReplySessionInitRetryDelayMs(err: unknown, attempt: number): number | undefined {\n"
|
||||
" const delayMs = REPLY_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && REPLY_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n\n"
|
||||
"export async function processOneMessageWithReplySessionRetry(\n"
|
||||
" full: WeixinMessage,\n"
|
||||
" deps: ProcessMessageDeps,\n"
|
||||
" abortSignal?: AbortSignal,\n"
|
||||
"): Promise<void> {\n"
|
||||
" for (let attempt = 0; ; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" await processOneMessage(full, deps);\n"
|
||||
" return;\n"
|
||||
" } catch (err) {\n"
|
||||
" const delayMs = resolveReplySessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null) throw err;\n"
|
||||
" deps.errLog(\n"
|
||||
" `weixin reply session conflict; retry=${attempt + 1}/${REPLY_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`,\n"
|
||||
" );\n"
|
||||
" await sleep(delayMs, abortSignal);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
),
|
||||
(
|
||||
" await processOneMessage(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => {}),\n"
|
||||
" errLog,\n"
|
||||
" });",
|
||||
" await processOneMessageWithReplySessionRetry(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => {}),\n"
|
||||
" errLog,\n"
|
||||
" }, abortSignal);",
|
||||
),
|
||||
],
|
||||
"dist/src/monitor/monitor.js": [
|
||||
(
|
||||
"const RETRY_DELAY_MS = 2_000;\n",
|
||||
"const RETRY_DELAY_MS = 2_000;\n"
|
||||
"const REPLY_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const REPLY_SESSION_INIT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000];\n",
|
||||
),
|
||||
(
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
"export function resolveReplySessionInitRetryDelayMs(err, attempt) {\n"
|
||||
" const delayMs = REPLY_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && REPLY_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n"
|
||||
"export async function processOneMessageWithReplySessionRetry(full, deps, abortSignal) {\n"
|
||||
" for (let attempt = 0;; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" await processOneMessage(full, deps);\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" catch (err) {\n"
|
||||
" const delayMs = resolveReplySessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null)\n"
|
||||
" throw err;\n"
|
||||
" deps.errLog(`weixin reply session conflict; retry=${attempt + 1}/${REPLY_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`);\n"
|
||||
" await sleep(delayMs, abortSignal);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
),
|
||||
(
|
||||
" await processOneMessage(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => { }),\n"
|
||||
" errLog,\n"
|
||||
" });",
|
||||
" await processOneMessageWithReplySessionRetry(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => { }),\n"
|
||||
" errLog,\n"
|
||||
" }, abortSignal);",
|
||||
),
|
||||
],
|
||||
"src/messaging/process-message.ts": [
|
||||
(
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0][\"ctx\"],\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" });",
|
||||
" const sessionMetaTasks: Promise<unknown>[] = [];\n"
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0][\"ctx\"],\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" trackSessionMetaTask: (task) => sessionMetaTasks.push(task),\n"
|
||||
" });\n"
|
||||
" await Promise.all(sessionMetaTasks);",
|
||||
),
|
||||
(
|
||||
"// AIOS Hermes image follow-up helpers end\n\nexport async function processOneMessage(\n",
|
||||
"// AIOS Hermes image follow-up helpers end\n\n"
|
||||
"const DISPATCH_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const DISPATCH_SESSION_INIT_RETRY_DELAYS_MS = [100, 250, 500, 1_000] as const;\n\n"
|
||||
"export function resolveDispatchSessionInitRetryDelayMs(err: unknown, attempt: number): number | undefined {\n"
|
||||
" const delayMs = DISPATCH_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && DISPATCH_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n\n"
|
||||
"export async function dispatchWithSessionInitConflictRetry<T>(\n"
|
||||
" run: () => Promise<T>,\n"
|
||||
" onRetry: (attempt: number, delayMs: number) => void,\n"
|
||||
"): Promise<T> {\n"
|
||||
" for (let attempt = 0; ; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" return await run();\n"
|
||||
" } catch (err) {\n"
|
||||
" const delayMs = resolveDispatchSessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null) throw err;\n"
|
||||
" onRetry(attempt + 1, delayMs);\n"
|
||||
" await new Promise((resolve) => setTimeout(resolve, delayMs));\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"export async function processOneMessage(\n",
|
||||
),
|
||||
(
|
||||
" try {\n"
|
||||
" await deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () =>\n"
|
||||
" deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" });",
|
||||
" try {\n"
|
||||
" await dispatchWithSessionInitConflictRetry(\n"
|
||||
" () => deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () =>\n"
|
||||
" deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" }),\n"
|
||||
" (attempt, delayMs) => deps.errLog(\n"
|
||||
" `weixin dispatch session conflict; retry=${attempt}/${DISPATCH_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`,\n"
|
||||
" ),\n"
|
||||
" );",
|
||||
),
|
||||
],
|
||||
"dist/src/messaging/process-message.js": [
|
||||
(
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized,\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" });",
|
||||
" const sessionMetaTasks = [];\n"
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized,\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" trackSessionMetaTask: (task) => sessionMetaTasks.push(task),\n"
|
||||
" });\n"
|
||||
" await Promise.all(sessionMetaTasks);",
|
||||
),
|
||||
(
|
||||
"// AIOS Hermes image follow-up helpers end\n\nexport async function processOneMessage(full, deps) {\n",
|
||||
"// AIOS Hermes image follow-up helpers end\n\n"
|
||||
"const DISPATCH_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const DISPATCH_SESSION_INIT_RETRY_DELAYS_MS = [100, 250, 500, 1_000];\n"
|
||||
"export function resolveDispatchSessionInitRetryDelayMs(err, attempt) {\n"
|
||||
" const delayMs = DISPATCH_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && DISPATCH_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n"
|
||||
"export async function dispatchWithSessionInitConflictRetry(run, onRetry) {\n"
|
||||
" for (let attempt = 0;; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" return await run();\n"
|
||||
" }\n"
|
||||
" catch (err) {\n"
|
||||
" const delayMs = resolveDispatchSessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null)\n"
|
||||
" throw err;\n"
|
||||
" onRetry(attempt + 1, delayMs);\n"
|
||||
" await new Promise((resolve) => setTimeout(resolve, delayMs));\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"export async function processOneMessage(full, deps) {\n",
|
||||
),
|
||||
(
|
||||
" try {\n"
|
||||
" await deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () => deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" });",
|
||||
" try {\n"
|
||||
" await dispatchWithSessionInitConflictRetry(\n"
|
||||
" () => deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () => deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" }),\n"
|
||||
" (attempt, delayMs) => deps.errLog(`weixin dispatch session conflict; retry=${attempt}/${DISPATCH_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`),\n"
|
||||
" );",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_root(root: Path) -> Path:
|
||||
root = root.resolve(strict=True)
|
||||
manifest = root / "package.json"
|
||||
if not manifest.is_file() or manifest.is_symlink():
|
||||
raise RuntimeError("invalid_plugin_manifest")
|
||||
package = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if package.get("name") != "@tencent-weixin/openclaw-weixin":
|
||||
raise RuntimeError("unexpected_plugin_package")
|
||||
return root
|
||||
|
||||
|
||||
def render_patch(path: Path, patches: list[tuple[str, str]]) -> tuple[str, bool]:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError(f"unsafe_or_missing_target:{path.name}")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
changed = False
|
||||
for old, new in patches:
|
||||
if new in text:
|
||||
continue
|
||||
if text.count(old) != 1:
|
||||
raise RuntimeError(f"target_shape_mismatch:{path.name}")
|
||||
text = text.replace(old, new, 1)
|
||||
changed = True
|
||||
return text, changed
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: str) -> None:
|
||||
mode = path.stat().st_mode & 0o777
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def patch_plugin(root: Path, *, apply: bool, backup_dir: Path | None = None) -> dict:
|
||||
root = validate_root(root)
|
||||
if apply and backup_dir is None:
|
||||
raise RuntimeError("backup_dir_required")
|
||||
rendered = {}
|
||||
result = {"status": "CHECKED", "changed": False, "files": {}}
|
||||
for relative, patches in PATCHES.items():
|
||||
path = root / relative
|
||||
content, changed = render_patch(path, patches)
|
||||
rendered[path] = (relative, content, changed)
|
||||
result["changed"] = result["changed"] or changed
|
||||
result["files"][relative] = {"before_sha256": sha256(path), "changed": changed}
|
||||
if apply:
|
||||
backup_dir = backup_dir.resolve()
|
||||
backup_dir.mkdir(parents=True, exist_ok=False)
|
||||
for path, (relative, content, changed) in rendered.items():
|
||||
backup = backup_dir / relative
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, backup, follow_symlinks=False)
|
||||
if changed:
|
||||
atomic_write(path, content)
|
||||
result["files"][relative]["after_sha256"] = sha256(path)
|
||||
result["files"][relative]["backup_sha256"] = sha256(backup)
|
||||
result["status"] = "APPLIED"
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-root", type=Path, required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--backup-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
result = patch_plugin(args.plugin_root, apply=args.apply, backup_dir=args.backup_dir)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
|
||||
"base": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-21T15:17:54Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/codex/AIOS-V2R1-RUN-PROVENANCE-CONTROL-20260720",
|
||||
"base": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-20T12:11:59Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"schema_version": "aios.execution-plan.v2",
|
||||
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE-20260721",
|
||||
"source_branch": "patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
|
||||
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
|
||||
"target_version": "V2R1C2",
|
||||
"release_gate_policy": "all_required_stages",
|
||||
"metadata": {
|
||||
"owner": "AIOS-Control-Plane",
|
||||
"purpose": "Unified role-aware portal and portable executor rollout",
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"owner_notification_channel": "wechat",
|
||||
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"production_baseline": "V2R1C1",
|
||||
"candidate_port": 18921,
|
||||
"production_port": 18920,
|
||||
"production_mutation_allowed": false
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "openclaw-candidate-deploy",
|
||||
"task_id": "AIOS-V2R1C2-OPENCLAW-CANDIDATE-DEPLOY-20260721",
|
||||
"executor_type": "openclaw",
|
||||
"role": "candidate_deployment_operator",
|
||||
"depends_on": [],
|
||||
"steps": [
|
||||
"verify_exact_source_oid_and_clean_worktree",
|
||||
"bootstrap_isolated_candidate_state_and_executor_catalog",
|
||||
"run_static_compile_and_candidate_unit_tests",
|
||||
"install_and_start_v2r1c2_candidate_service_on_18921",
|
||||
"run_health_api_ui_and_role_matrix_smoke",
|
||||
"run_tenant_isolation_and_protected_action_negative_tests",
|
||||
"run_restart_recovery_and_rollback_drill",
|
||||
"publish_candidate_evidence_manifest"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_oid_attestation",
|
||||
"bootstrap_log",
|
||||
"compile_report",
|
||||
"unit_test_report",
|
||||
"service_status",
|
||||
"health_check",
|
||||
"api_smoke",
|
||||
"ui_smoke",
|
||||
"role_matrix",
|
||||
"tenant_isolation",
|
||||
"protected_action_negative",
|
||||
"restart_recovery",
|
||||
"rollback_drill",
|
||||
"candidate_evidence_manifest"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "openclaw",
|
||||
"candidate_service": "aios-v2r1c2-portal.service",
|
||||
"candidate_url": "http://127.0.0.1:18921",
|
||||
"production_service_must_remain_untouched": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "hermes-runtime-validation",
|
||||
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260721",
|
||||
"executor_type": "hermes",
|
||||
"role": "runtime_validation_operator",
|
||||
"depends_on": [
|
||||
"openclaw-candidate-deploy"
|
||||
],
|
||||
"steps": [
|
||||
"validate_hermes_health_and_worker_identity",
|
||||
"validate_queue_claim_and_completion",
|
||||
"validate_lease_heartbeat_and_timeout_recovery",
|
||||
"validate_restart_recovery_and_duplicate_delivery_safety",
|
||||
"reconcile_runtime_evidence_with_candidate_audit"
|
||||
],
|
||||
"required_evidence": [
|
||||
"hermes_health",
|
||||
"queue_claim_completion",
|
||||
"lease_heartbeat",
|
||||
"timeout_recovery",
|
||||
"restart_recovery",
|
||||
"duplicate_delivery_safety",
|
||||
"runtime_evidence_reconciliation"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "hermes",
|
||||
"candidate_url": "http://127.0.0.1:18921"
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "codex-release-review",
|
||||
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260721",
|
||||
"executor_type": "codex",
|
||||
"role": "release_evidence_reviewer",
|
||||
"depends_on": [
|
||||
"openclaw-candidate-deploy",
|
||||
"hermes-runtime-validation"
|
||||
],
|
||||
"steps": [
|
||||
"review_source_diff_and_architecture_invariants",
|
||||
"review_test_and_runtime_evidence_hashes",
|
||||
"review_security_tenant_and_role_boundaries",
|
||||
"review_documentation_traceability_and_rollback_readiness",
|
||||
"produce_release_recommendation_without_creating_tag"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_diff_review",
|
||||
"test_evidence_review",
|
||||
"runtime_evidence_review",
|
||||
"security_boundary_review",
|
||||
"documentation_traceability_review",
|
||||
"rollback_readiness_review",
|
||||
"release_recommendation"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "codex",
|
||||
"tag_creation_allowed": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"schema_version": "aios.execution-plan.v2",
|
||||
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
|
||||
"source_branch": "patch/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
|
||||
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
|
||||
"target_version": "V2R1C2",
|
||||
"release_gate_policy": "all_required_stages",
|
||||
"metadata": {
|
||||
"owner": "AIOS-Control-Plane",
|
||||
"purpose": "Legal ChatGPT supervisor takeover for the isolated Unified Portal candidate",
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"owner_notification_channel": "wechat-simulation",
|
||||
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"production_baseline": "V2R1C1",
|
||||
"candidate_port": 18921,
|
||||
"production_port": 18920,
|
||||
"production_mutation_allowed": false,
|
||||
"release_tag_creation_allowed": false,
|
||||
"simulation_required": true
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "chatgpt-supervisor-candidate-deploy",
|
||||
"task_id": "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
|
||||
"executor_type": "chatgpt-supervisor",
|
||||
"role": "candidate_deployment_supervisor",
|
||||
"depends_on": [],
|
||||
"steps": [
|
||||
"verify_exact_source_oid_and_clean_worktree",
|
||||
"verify_owner_provenance_and_tenant_boundary",
|
||||
"compile_and_test_candidate",
|
||||
"deploy_isolated_candidate_on_18921",
|
||||
"run_offline_and_online_acceptance",
|
||||
"run_simulated_wechat_acceptance",
|
||||
"run_restart_recovery_and_rollback_drill",
|
||||
"publish_hashed_evidence_and_gate_report"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_oid_attestation",
|
||||
"provenance_positive_path",
|
||||
"provenance_negative_matrix",
|
||||
"wechat_simulated_transcript",
|
||||
"wechat_simulated_receipts",
|
||||
"provenance_replay_test",
|
||||
"tenant_isolation",
|
||||
"provider_identity_test",
|
||||
"compile_report",
|
||||
"unit_test_report",
|
||||
"candidate_regression_report",
|
||||
"acceptance_offline",
|
||||
"acceptance_online",
|
||||
"service_status",
|
||||
"health_check",
|
||||
"api_smoke",
|
||||
"ui_smoke",
|
||||
"role_matrix",
|
||||
"restart_recovery",
|
||||
"rollback_drill",
|
||||
"production_health_before",
|
||||
"production_health_after",
|
||||
"final_execution_snapshot",
|
||||
"final_gate_report"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "chatgpt-supervisor",
|
||||
"candidate_service": "aios-v2r1c2-portal.service",
|
||||
"candidate_url": "http://127.0.0.1:18921",
|
||||
"production_url": "http://127.0.0.1:18920",
|
||||
"production_service_must_remain_untouched": true,
|
||||
"wechat_transport": "mock-or-sandbox",
|
||||
"real_user_impersonated": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "hermes-runtime-validation",
|
||||
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260722",
|
||||
"executor_type": "hermes",
|
||||
"role": "runtime_validation_operator",
|
||||
"depends_on": ["chatgpt-supervisor-candidate-deploy"],
|
||||
"steps": [
|
||||
"validate_hermes_health_and_worker_identity",
|
||||
"validate_queue_claim_and_completion",
|
||||
"validate_lease_heartbeat_and_timeout_recovery",
|
||||
"validate_restart_recovery_and_duplicate_delivery_safety",
|
||||
"reconcile_runtime_evidence_with_candidate_audit"
|
||||
],
|
||||
"required_evidence": [
|
||||
"hermes_health",
|
||||
"queue_claim_completion",
|
||||
"lease_heartbeat",
|
||||
"timeout_recovery",
|
||||
"restart_recovery",
|
||||
"duplicate_delivery_safety",
|
||||
"runtime_evidence_reconciliation"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "hermes",
|
||||
"candidate_url": "http://127.0.0.1:18921"
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "codex-release-review",
|
||||
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260722",
|
||||
"executor_type": "codex",
|
||||
"role": "release_evidence_reviewer",
|
||||
"depends_on": [
|
||||
"chatgpt-supervisor-candidate-deploy",
|
||||
"hermes-runtime-validation"
|
||||
],
|
||||
"steps": [
|
||||
"review_source_diff_and_architecture_invariants",
|
||||
"review_test_and_runtime_evidence_hashes",
|
||||
"review_security_tenant_and_role_boundaries",
|
||||
"review_documentation_traceability_and_rollback_readiness",
|
||||
"produce_release_recommendation_without_creating_tag"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_diff_review",
|
||||
"test_evidence_review",
|
||||
"runtime_evidence_review",
|
||||
"security_boundary_review",
|
||||
"documentation_traceability_review",
|
||||
"rollback_readiness_review",
|
||||
"release_recommendation"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "codex",
|
||||
"tag_creation_allowed": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# AIOS OpenClaw Run Provenance Writer
|
||||
|
||||
本插件为 AIOS V2R1 提供 Owner 签名的运行授权、持久 Provenance Store 与专用 Evidence Writer。它不授予通用 Shell 或任意文件写权限。
|
||||
|
||||
## AIOS Runtime HTTP 合同
|
||||
|
||||
插件在 OpenClaw loopback Gateway 内注册 `/v1/health`、`/v1/execute` 和
|
||||
`/v1/commands/<id>`。执行请求必须同时通过本机 CredentialRef 比对与 Ed25519
|
||||
签名验证;热路径只读取公钥,不读取 Candidate 私密签名材料。
|
||||
|
||||
Runtime 仅允许无副作用的 `status` 与在线验收专用 `acceptance.delay`。它不把
|
||||
返回成功冒充为模型推理或任意 Agent 执行。命令身份、幂等键和终态以追加式
|
||||
JSONL 持久化;Gateway 重启时遗留的 `RUNNING` 命令会收敛为
|
||||
`FAILED/restart_interrupted`,避免把重启误报成真实超时。
|
||||
|
||||
取消只对当前进程内仍在执行的命令生效,超时由签名命令的 `expires_at` 与
|
||||
30 秒硬上限共同约束。未知 action、错误签名、过期命令、跨 tenant 状态查询、
|
||||
冲突的 `command_id` 或幂等键均失败关闭。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 签名合同:`aios.run-provenance/v1`,Ed25519,授权最长 15 分钟。
|
||||
- 授权绑定:`run_id`、`sender_id`、任务、执行器实例、当前 Claim OID、Candidate OID 与任务分支。
|
||||
- 路径:仅允许七个 `.agent/*.json` Evidence 文件。
|
||||
- 操作:仅允许 `create/update`,随后执行固定 argv 的 `git add`、`git commit` 和当前任务分支普通 push。
|
||||
- 通用 `exec/shell/bash/write/edit/apply_patch/file_write` 始终拒绝。
|
||||
- `toolCallId` permit 只能消费一次;参数摘要变化、过期、签名篡改、Run/Sender 不匹配均拒绝。
|
||||
- Writer 拒绝 symlink、路径逃逸、脏 Worktree、错误分支、错误 HEAD、Candidate 非祖先及暂存范围扩大。
|
||||
- Gateway 以服务用户调用 Git 时,仅对配置中的精确 `writerRepoRoot` 注入命令级 `safe.directory`,不写全局 Git 配置。
|
||||
- 禁止 reset、rebase、amend、Tag 和 force push。提交后 push 失败时保留可审计的 local-ahead 状态,不伪造回滚。
|
||||
|
||||
## 组件
|
||||
|
||||
- `issuer.mjs`:在受保护边界读取现有 Ed25519 材料,生成公开验签材料并原子写入一次性签发记录。
|
||||
- `provenance.js`:严格 Schema、canonical JSON、Ed25519 sign/verify 和安全 Store 读取。
|
||||
- `index.js`:OpenClaw hook、Owner/Run/Sender 验证与一次性 permit。
|
||||
- `writer.js`:限定路径原子写入、固定 Git 操作和追加式审计。
|
||||
|
||||
## 部署与回滚
|
||||
|
||||
部署必须先备份运行插件和 OpenClaw 配置,再安装 Git 中已测试的完整目录,配置绝对路径、Key ID、任务分支和审计路径,执行 `openclaw plugins doctor` 后才重启 Gateway。回滚只恢复备份插件与配置并重启,不修改任务分支历史或业务状态。
|
||||
|
||||
## 验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
node --test test/*.test.js
|
||||
```
|
||||
|
||||
验收还必须包含受保护签发、Heartbeat 实际 commit/push、审计链验证,以及 `README.md` 等未授权路径的拒绝测试。
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "../index.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../policy.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../provenance.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../writer.js";
|
||||
@@ -0,0 +1,418 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
canonicalize,
|
||||
isOwnerProvenanceRevoked,
|
||||
loadStoredRecord,
|
||||
readPublicKey,
|
||||
validatePayload,
|
||||
verifyOwnerProvenanceRecord,
|
||||
verifyRecord,
|
||||
} from "./provenance.js";
|
||||
import { issueOwnerProvenance } from "./issuer.mjs";
|
||||
import { classifyRun, decideConversationRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||
import { executeEvidenceWrite } from "./writer.js";
|
||||
import { createRuntimeApi } from "./runtime.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const permits = new Map();
|
||||
const ownerPermits = new Map();
|
||||
const ownerUses = new Map();
|
||||
const dispatchReceipts = new Map();
|
||||
const dispatchAttempts = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_DISPATCH_CACHE_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_SECURITY_CACHE_ENTRIES = 4096;
|
||||
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_PROTECTED_DISPATCH_PROVIDER = "chatgpt-supervisor";
|
||||
|
||||
function pluginConfig(event, api) {
|
||||
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
}
|
||||
|
||||
function protectedDispatchProvider(config = {}) {
|
||||
return String(config.protectedDispatchProvider || DEFAULT_PROTECTED_DISPATCH_PROVIDER);
|
||||
}
|
||||
|
||||
function ownerPrincipalId(senderId) {
|
||||
const digest = crypto.createHash("sha256").update(String(senderId)).digest("hex");
|
||||
return `principal:sha256:${digest}`;
|
||||
}
|
||||
|
||||
function trimToLimit(map, limit = MAX_SECURITY_CACHE_ENTRIES) {
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
}
|
||||
|
||||
function setBounded(map, key, value) {
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
trimToLimit(map);
|
||||
}
|
||||
|
||||
export function prune(now = Date.now()) {
|
||||
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
|
||||
for (const [key, value] of ownerPermits) if (now - value.recordedAt > value.ttlMs) ownerPermits.delete(key);
|
||||
for (const [key, expiresAt] of ownerUses) if (now >= expiresAt) ownerUses.delete(key);
|
||||
for (const [key, value] of dispatchReceipts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchReceipts.delete(key);
|
||||
}
|
||||
for (const [key, value] of dispatchAttempts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchAttempts.delete(key);
|
||||
}
|
||||
for (const map of [activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts]) trimToLimit(map);
|
||||
}
|
||||
|
||||
function toolCallId(event) {
|
||||
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
|
||||
}
|
||||
|
||||
function eventParams(event) {
|
||||
return event?.params ?? event?.parameters;
|
||||
}
|
||||
|
||||
function requireAbsoluteConfig(config, key) {
|
||||
const value = config[key];
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
|
||||
const params = eventParams(event);
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
|
||||
let record;
|
||||
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
|
||||
? error.message : "provenance_store_rejected";
|
||||
return { ok: false, code };
|
||||
}
|
||||
const validation = validatePayload(record.payload, now);
|
||||
if (!validation.ok) return validation;
|
||||
let publicKey;
|
||||
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
|
||||
catch { return { ok: false, code: "public_key_rejected" }; }
|
||||
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
|
||||
if (!signature.ok) return signature;
|
||||
const payload = record.payload;
|
||||
const runId = runKey(event, {});
|
||||
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
|
||||
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
|
||||
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
|
||||
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
|
||||
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
|
||||
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
|
||||
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
|
||||
const paramsValidation = validateWriterParams(params, payload);
|
||||
if (!paramsValidation.ok) return paramsValidation;
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
|
||||
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
|
||||
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
|
||||
: DEFAULT_PERMIT_TTL_MS;
|
||||
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = permits.get(toolCallIdValue);
|
||||
permits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
|
||||
return permit;
|
||||
}
|
||||
|
||||
function protectedDispatchParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "protected_params_missing" };
|
||||
const expected = ["action", "idempotency_key", "payload", "provider", "task_id"];
|
||||
const keys = Object.keys(params).sort();
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) {
|
||||
return { ok: false, code: "protected_params_schema_mismatch" };
|
||||
}
|
||||
for (const key of ["action", "idempotency_key", "provider", "task_id"]) {
|
||||
if (typeof params[key] !== "string" || !params[key].trim()) return { ok: false, code: `invalid_${key}` };
|
||||
}
|
||||
if (!params.payload || typeof params.payload !== "object" || Array.isArray(params.payload)) return { ok: false, code: "invalid_payload" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function verifyOwnerBinding({ record, publicKeyPem, expectedKeyId, expected, storeDir, now = Date.now() }) {
|
||||
const verified = verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now);
|
||||
if (!verified.ok) return verified;
|
||||
if (isOwnerProvenanceRevoked(storeDir, record.payload.provenance_id)) return { ok: false, code: "owner_revoked" };
|
||||
for (const key of ["tenant_id", "principal_id", "task_id", "agent_id", "run_id", "execution_path", "request_digest"]) {
|
||||
if (record.payload[key] !== expected[key]) return { ok: false, code: `${key}_mismatch` };
|
||||
}
|
||||
return { ok: true, payload: record.payload };
|
||||
}
|
||||
|
||||
export function authorizeProtectedDispatchCall({ event, ctx = {}, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted || trust.origin !== "authenticated_owner") return { ok: false, code: trust?.reason || "owner_not_authenticated" };
|
||||
const params = eventParams(event);
|
||||
const paramsCheck = protectedDispatchParams(params);
|
||||
if (!paramsCheck.ok) return paramsCheck;
|
||||
const expectedProvider = protectedDispatchProvider(config);
|
||||
const request = { ...params, provider: expectedProvider };
|
||||
const principalId = ownerPrincipalId(trust.senderId);
|
||||
const runId = runKey(event, ctx) || trust.runId;
|
||||
const agentId = String(ctx.agentId || trust.agentId || "");
|
||||
const tenantId = String(config.tenantId || "");
|
||||
const executionPath = String(trust.executionPath || "");
|
||||
if (!runId || !agentId || !tenantId || !executionPath) return { ok: false, code: "server_identity_incomplete" };
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(request)).digest("hex");
|
||||
let issued;
|
||||
try {
|
||||
issued = issueOwnerProvenance({
|
||||
identity: {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
},
|
||||
request,
|
||||
privateKeyPath: requireAbsoluteConfig(config, "provenancePrivateKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
ttlMs: Math.min(Number(config.ownerProvenanceTtlMs || 120_000), 300_000),
|
||||
now,
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: error instanceof Error ? error.message : "owner_issue_failed" };
|
||||
}
|
||||
const expected = {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
request_digest: requestDigest,
|
||||
};
|
||||
const binding = verifyOwnerBinding({
|
||||
record: issued.record,
|
||||
publicKeyPem: readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")),
|
||||
expectedKeyId: String(config.provenanceKeyId || ""),
|
||||
expected,
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) return binding;
|
||||
if (ownerPermits.has(callId)) return { ok: false, code: "owner_permit_replay" };
|
||||
const ttlMs = Math.min(Number(config.permitTtlMs || DEFAULT_PERMIT_TTL_MS), DEFAULT_PERMIT_TTL_MS);
|
||||
ownerPermits.set(callId, {
|
||||
record: issued.record,
|
||||
expected,
|
||||
request,
|
||||
callerParamsDigest,
|
||||
paramsDigest: requestDigest,
|
||||
recordedAt: now,
|
||||
ttlMs,
|
||||
publicKeyPath: requireAbsoluteConfig(config, "provenancePublicKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
});
|
||||
return { ok: true, provenance_id: issued.provenanceId };
|
||||
}
|
||||
|
||||
export function consumeProtectedPermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = ownerPermits.get(toolCallIdValue);
|
||||
ownerPermits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("owner_permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("owner_permit_expired");
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(callerParamsDigest), Buffer.from(permit.callerParamsDigest))) {
|
||||
throw new Error("owner_permit_params_mismatch");
|
||||
}
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(permit.request)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(requestDigest), Buffer.from(permit.paramsDigest))) {
|
||||
throw new Error("owner_permit_request_mismatch");
|
||||
}
|
||||
const binding = verifyOwnerBinding({
|
||||
record: permit.record,
|
||||
publicKeyPem: readPublicKey(permit.publicKeyPath),
|
||||
expectedKeyId: permit.keyId,
|
||||
expected: permit.expected,
|
||||
storeDir: permit.storeDir,
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) throw new Error(binding.code);
|
||||
const provenanceId = permit.record.payload.provenance_id;
|
||||
if (ownerUses.has(provenanceId)) throw new Error("owner_provenance_replay");
|
||||
setBounded(ownerUses, provenanceId, permit.record.payload.expires_at);
|
||||
return permit;
|
||||
}
|
||||
|
||||
function toolResult(details) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
||||
}
|
||||
|
||||
function registerWriterTool(api) {
|
||||
api.registerTool((toolContext) => ({
|
||||
name: "aios_evidence_write",
|
||||
label: "AIOS Evidence Writer",
|
||||
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["provenance_id", "path", "operation", "document"],
|
||||
properties: {
|
||||
provenance_id: { type: "string" },
|
||||
path: { type: "string" },
|
||||
operation: { type: "string", enum: ["create", "update"] },
|
||||
document: { type: "object" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumePermit(callId, params);
|
||||
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
|
||||
?? api.pluginConfig ?? {};
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
|
||||
evidencePath: params.path,
|
||||
operation: params.operation,
|
||||
document: params.document,
|
||||
payload: permit.payload,
|
||||
pushRemote: config.writerPushRemote ?? "gitea-http",
|
||||
askPassPath: config.writerAskPassPath ?? "",
|
||||
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
|
||||
gitBinary: config.gitBinary ?? "/usr/bin/git",
|
||||
});
|
||||
return toolResult(result);
|
||||
},
|
||||
}), { name: "aios_evidence_write" });
|
||||
}
|
||||
|
||||
function registerProtectedDispatchTool(api) {
|
||||
const expectedProvider = protectedDispatchProvider(api.pluginConfig);
|
||||
api.registerTool(() => ({
|
||||
name: "aios_protected_dispatch",
|
||||
label: "AIOS Protected Dispatch",
|
||||
description: "Dispatch one Owner-authenticated request with server-issued provenance.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["task_id", "provider", "action", "payload", "idempotency_key"],
|
||||
properties: {
|
||||
task_id: { type: "string" },
|
||||
provider: { type: "string", enum: [expectedProvider] },
|
||||
action: { type: "string" },
|
||||
payload: { type: "object" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumeProtectedPermit(callId, params);
|
||||
const request = permit.request;
|
||||
const config = api.pluginConfig ?? {};
|
||||
const mode = String(config.protectedDispatchTransport || "disabled");
|
||||
if (!new Set(["mock", "sandbox"]).has(mode)) throw new Error("protected_dispatch_transport_not_authorized");
|
||||
const dedupeKey = `${permit.record.payload.tenant_id}\0${permit.record.payload.principal_id}\0${request.idempotency_key}`;
|
||||
const prior = dispatchReceipts.get(dedupeKey);
|
||||
if (prior) {
|
||||
if (prior.paramsDigest !== permit.paramsDigest) throw new Error("protected_dispatch_idempotency_conflict");
|
||||
return toolResult({ ...prior.result, deduplicated: true });
|
||||
}
|
||||
const attempts = (dispatchAttempts.get(dedupeKey)?.attempts || 0) + 1;
|
||||
setBounded(dispatchAttempts, dedupeKey, { attempts, recordedAt: Date.now() });
|
||||
if (config.mockFailFirstDelivery === true && attempts === 1) throw new Error("simulated_delivery_failure");
|
||||
const result = {
|
||||
accepted: true,
|
||||
task_id: request.task_id,
|
||||
provider: request.provider,
|
||||
provider_source: "server_config",
|
||||
provider_hint_overridden: params.provider !== request.provider,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: true,
|
||||
transport: mode,
|
||||
real_user_impersonated: false,
|
||||
attempts,
|
||||
};
|
||||
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
|
||||
return toolResult(result);
|
||||
},
|
||||
}), { name: "aios_protected_dispatch" });
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "aios-tenant-boundary",
|
||||
name: "AIOS Tenant Boundary",
|
||||
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
|
||||
register(api) {
|
||||
const runtimeApi = createRuntimeApi(api);
|
||||
api.registerHttpRoute({ path: "/v1/health", auth: "plugin", match: "exact", handler: runtimeApi.handleHealth });
|
||||
api.registerHttpRoute({ path: "/v1/execute", auth: "plugin", match: "exact", handler: runtimeApi.handleExecute });
|
||||
api.registerHttpRoute({ path: "/v1/commands/", auth: "plugin", match: "prefix", handler: runtimeApi.commandRequest });
|
||||
registerWriterTool(api);
|
||||
registerProtectedDispatchTool(api);
|
||||
api.on("before_agent_run", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = classifyRun(event, ctx, config);
|
||||
const key = runKey(event, ctx);
|
||||
if (key) {
|
||||
const channel = String(event.channelId || ctx.channelId || "unknown");
|
||||
const session = String(ctx.sessionId || ctx.sessionKey || "unknown");
|
||||
activeRuns.set(key, {
|
||||
...trust,
|
||||
agentId: String(ctx.agentId || ""),
|
||||
runId: key,
|
||||
executionPath: `channel:${channel}/session:${session}`,
|
||||
recordedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
const conversationDecision = decideConversationRun(trust, config);
|
||||
if (conversationDecision.action === "block" &&
|
||||
(config.blockUnknownAgentRuns === true || trust.reason === "sender_blocked")) {
|
||||
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
|
||||
}
|
||||
return { outcome: "pass" };
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("before_tool_call", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = activeRuns.get(runKey(event, ctx));
|
||||
const decision = decideToolCall(event.toolName, trust, config);
|
||||
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
|
||||
if (decision.action === "verify_provenance") {
|
||||
const authorized = authorizeEvidenceCall({ event, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "verify_owner_provenance") {
|
||||
const authorized = authorizeProtectedDispatchCall({ event, ctx, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS Owner provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "approval") {
|
||||
return { requireApproval: {
|
||||
title: `Approve ${String(event.toolName)}`,
|
||||
description: "Authenticated Owner requested a non-evidence side effect.",
|
||||
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
||||
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
|
||||
} };
|
||||
}
|
||||
return undefined;
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.delete(key);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const __state = { activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts };
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
canonicalize,
|
||||
privateKeyFromMaterial,
|
||||
readPrivateKeyMaterial,
|
||||
signRecord,
|
||||
validateOwnerProvenancePayload,
|
||||
validatePayload,
|
||||
} from "./provenance.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!key?.startsWith("--") || value === undefined) throw new Error("invalid_arguments");
|
||||
result[key.slice(2)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function atomicWrite(filePath, contents, mode) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||
fs.renameSync(temporary, filePath);
|
||||
fs.chmodSync(filePath, mode);
|
||||
}
|
||||
|
||||
function atomicCreate(filePath, contents, mode) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||
const parent = fs.realpathSync(path.dirname(filePath));
|
||||
const target = path.join(parent, path.basename(filePath));
|
||||
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||
fs.linkSync(temporary, target);
|
||||
fs.chmodSync(target, mode);
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") throw new Error("provenance_id_already_issued");
|
||||
throw error;
|
||||
} finally {
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
|
||||
for (const value of [payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId]) {
|
||||
if (typeof value !== "string" || !value) throw new Error("missing_argument");
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
|
||||
const validated = validatePayload(payload, now);
|
||||
if (!validated.ok) throw new Error(validated.code);
|
||||
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
|
||||
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
|
||||
const recordPath = path.join(fs.realpathSync(storeDir), `${payload.provenance_id}.json`);
|
||||
const privateKey = privateKeyFromMaterial(privateMaterial);
|
||||
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
|
||||
const record = signRecord(payload, privateMaterial, keyId);
|
||||
atomicWrite(publicKeyPath, publicPem, 0o644);
|
||||
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return payload.provenance_id;
|
||||
}
|
||||
|
||||
export function issueOwnerProvenance({
|
||||
identity,
|
||||
request,
|
||||
privateKeyPath,
|
||||
storeDir,
|
||||
keyId,
|
||||
ttlMs = 120_000,
|
||||
now = Date.now(),
|
||||
provenanceId = `owner-${crypto.randomUUID()}`,
|
||||
}) {
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("owner_identity_missing");
|
||||
for (const value of [privateKeyPath, storeDir, keyId]) {
|
||||
if (typeof value !== "string" || !value) throw new Error("missing_owner_issuer_argument");
|
||||
}
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1000 || ttlMs > 300_000) throw new Error("invalid_owner_ttl");
|
||||
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
|
||||
const payload = {
|
||||
schema_version: "aios.owner-provenance/v2",
|
||||
provenance_id: provenanceId,
|
||||
tenant_id: identity.tenant_id,
|
||||
principal_id: identity.principal_id,
|
||||
task_id: identity.task_id,
|
||||
agent_id: identity.agent_id,
|
||||
run_id: identity.run_id,
|
||||
execution_path: identity.execution_path,
|
||||
request_digest: crypto.createHash("sha256").update(canonicalize(request)).digest("hex"),
|
||||
issued_at: now,
|
||||
expires_at: now + ttlMs,
|
||||
key_id: keyId,
|
||||
};
|
||||
const validated = validateOwnerProvenancePayload(payload, now);
|
||||
if (!validated.ok) throw new Error(validated.code);
|
||||
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
|
||||
const recordPath = path.join(fs.realpathSync(storeDir), `${provenanceId}.json`);
|
||||
const record = signRecord(payload, privateMaterial, keyId);
|
||||
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return { provenanceId, recordPath, record };
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const provenanceId = issueFromFiles({
|
||||
payloadPath: args.payload,
|
||||
privateKeyPath: args["private-key"],
|
||||
publicKeyPath: args["public-key"],
|
||||
storeDir: args["store-dir"],
|
||||
keyId: args["key-id"],
|
||||
});
|
||||
process.stdout.write(`PROVENANCE_ISSUED=${provenanceId}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`PROVENANCE_ISSUE_FAILED=${error instanceof Error ? error.message : "unknown"}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "aios-tenant-boundary",
|
||||
"name": "AIOS Tenant Boundary",
|
||||
"description": "Enforces Owner-signed run provenance, a dedicated evidence writer, and the signed AIOS runtime contract.",
|
||||
"version": "2.1.0-rc4",
|
||||
"entry": "dist/index.js",
|
||||
"contracts": {
|
||||
"tools": ["aios_evidence_write", "aios_protected_dispatch"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
||||
"requireSenderIsOwner": { "type": "boolean", "default": true },
|
||||
"allowUntrustedConversations": { "type": "boolean", "default": true },
|
||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||
"blockUnknownAgentRuns": { "type": "boolean", "default": true },
|
||||
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"provenanceStoreDir": { "type": "string" },
|
||||
"provenancePrivateKeyPath": { "type": "string" },
|
||||
"provenancePublicKeyPath": { "type": "string" },
|
||||
"provenanceKeyId": { "type": "string" },
|
||||
"provenanceIssuer": { "type": "string" },
|
||||
"tenantId": { "type": "string" },
|
||||
"ownerProvenanceTtlMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"protectedDispatchProvider": { "type": "string", "default": "chatgpt-supervisor" },
|
||||
"protectedDispatchTransport": { "type": "string", "enum": ["disabled", "mock", "sandbox"], "default": "disabled" },
|
||||
"mockFailFirstDelivery": { "type": "boolean", "default": false },
|
||||
"executor": { "type": "string" },
|
||||
"executorInstance": { "type": "string" },
|
||||
"writerRepoRoot": { "type": "string" },
|
||||
"writerTaskId": { "type": "string" },
|
||||
"writerTaskBranch": { "type": "string" },
|
||||
"writerPushRemote": { "type": "string" },
|
||||
"writerAskPassPath": { "type": "string" },
|
||||
"writerAuditLogPath": { "type": "string" },
|
||||
"gitBinary": { "type": "string" },
|
||||
"runtimeStatePath": { "type": "string" },
|
||||
"runtimeSigningPublicKeyPath": { "type": "string" },
|
||||
"runtimeSigningKeyId": { "type": "string" },
|
||||
"runtimeCredentialRef": { "type": "string" },
|
||||
"runtimeMaxActive": { "type": "integer", "minimum": 1, "maximum": 16 },
|
||||
"runtimeMaxDelayMs": { "type": "integer", "minimum": 100, "maximum": 30000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@aios/openclaw-tenant-boundary",
|
||||
"version": "2.1.0-rc.4",
|
||||
"type": "module",
|
||||
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
|
||||
"files": [
|
||||
"index.js",
|
||||
"policy.js",
|
||||
"provenance.js",
|
||||
"writer.js",
|
||||
"runtime.js",
|
||||
"issuer.mjs",
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
|
||||
|
||||
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
||||
]);
|
||||
const GENERIC_MUTATION_TOOLS = new Set([
|
||||
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
|
||||
"computer", "process", "terminal",
|
||||
]);
|
||||
|
||||
function strings(value) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
function firstString(values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasConversationInput(event = {}, ctx = {}) {
|
||||
return Boolean(firstString([
|
||||
event.prompt,
|
||||
ctx.prompt,
|
||||
event.input,
|
||||
ctx.input,
|
||||
event.text,
|
||||
ctx.text,
|
||||
event.message?.text,
|
||||
ctx.message?.text,
|
||||
event.message?.content,
|
||||
ctx.message?.content,
|
||||
event.channelContext?.text,
|
||||
ctx.channelContext?.text,
|
||||
event.channelContext?.message?.text,
|
||||
ctx.channelContext?.message?.text,
|
||||
]));
|
||||
}
|
||||
|
||||
/** Resolve only identities populated by the trusted OpenClaw hook boundary. */
|
||||
export function resolveSenderId(ctx = {}, event = {}) {
|
||||
return firstString([
|
||||
event.senderId,
|
||||
ctx.senderId,
|
||||
event.sender?.id,
|
||||
ctx.sender?.id,
|
||||
event.channelContext?.sender?.id,
|
||||
ctx.channelContext?.sender?.id,
|
||||
]);
|
||||
}
|
||||
|
||||
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
||||
const senderId = resolveSenderId(ctx, event);
|
||||
const trusted = new Set(strings(config.trustedSenderIds));
|
||||
const blocked = new Set(strings(config.blockedSenderIds));
|
||||
if (typeof ctx.jobId === "string" && ctx.jobId) {
|
||||
return { trusted: false, origin: "automation", reason: "job_id_present" };
|
||||
}
|
||||
if (!senderId) {
|
||||
return {
|
||||
trusted: false,
|
||||
origin: "system_or_unknown",
|
||||
reason: "sender_id_missing",
|
||||
conversationOnly: hasConversationInput(event, ctx),
|
||||
};
|
||||
}
|
||||
if (blocked.has(senderId)) {
|
||||
return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
||||
}
|
||||
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
||||
return {
|
||||
trusted: false,
|
||||
origin: "untrusted_sender",
|
||||
reason: "sender_not_allowlisted",
|
||||
senderId,
|
||||
conversationOnly: true,
|
||||
};
|
||||
}
|
||||
const singletonTrustedOwner = config.requireExplicitTrustedSenders !== false &&
|
||||
trusted.size === 1 && trusted.has(senderId);
|
||||
const ownerVerified = event.senderIsOwner === true || singletonTrustedOwner;
|
||||
if (config.requireSenderIsOwner !== false && !ownerVerified) {
|
||||
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
||||
}
|
||||
return {
|
||||
trusted: true,
|
||||
origin: "authenticated_owner",
|
||||
reason: event.senderIsOwner === true ? "owner_verified" : "owner_verified_by_singleton_trusted_binding",
|
||||
senderId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow identified/message-bearing runs to enter a conversation-only agent
|
||||
* session. Missing non-message system runs and explicitly blocked senders stay
|
||||
* fail-closed. Tool access is enforced separately by decideToolCall().
|
||||
*/
|
||||
export function decideConversationRun(trust, config = {}) {
|
||||
if (trust?.reason === "sender_blocked") {
|
||||
return { action: "block", code: "sender_blocked" };
|
||||
}
|
||||
if (trust?.trusted || trust?.reason === "sender_not_owner" || trust?.reason === "job_id_present") {
|
||||
return { action: "pass", code: trust?.reason || "trusted" };
|
||||
}
|
||||
if (trust?.conversationOnly === true) {
|
||||
return config.allowUntrustedConversations === false
|
||||
? { action: "block", code: trust?.reason || "untrusted_conversation" }
|
||||
: { action: "pass", code: "conversation_only" };
|
||||
}
|
||||
return { action: "block", code: trust?.reason || "run_provenance_missing" };
|
||||
}
|
||||
|
||||
export function runKey(event = {}, ctx = {}) {
|
||||
const key = event.runId || ctx.runId;
|
||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
||||
}
|
||||
|
||||
export function decideToolCall(toolName, runTrust, config = {}) {
|
||||
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
||||
if (!name) return { action: "block", code: "tool_name_missing" };
|
||||
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
|
||||
if (runTrust?.conversationOnly === true) {
|
||||
return { action: "block", code: "conversation_only_no_tools" };
|
||||
}
|
||||
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
||||
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
||||
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
|
||||
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
|
||||
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
|
||||
if (name === "aios_protected_dispatch") return { action: "verify_owner_provenance", code: "protected_dispatch" };
|
||||
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
||||
return { action: "approval", code: "human_side_effect" };
|
||||
}
|
||||
|
||||
export function validateWriterParams(params = {}, payload) {
|
||||
const keys = Object.keys(params).sort();
|
||||
const expected = ["document", "operation", "path", "provenance_id"];
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
|
||||
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
|
||||
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
|
||||
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
|
||||
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
|
||||
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
|
||||
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
|
||||
}
|
||||
let serialized;
|
||||
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
|
||||
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const ALLOWED_PATHS = Object.freeze([
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/status.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/credential-status.json",
|
||||
]);
|
||||
|
||||
export const ALLOWED_OPERATIONS = Object.freeze([
|
||||
"create",
|
||||
"update",
|
||||
"git add",
|
||||
"git commit",
|
||||
"normal push current task branch",
|
||||
]);
|
||||
|
||||
const PAYLOAD_FIELDS = Object.freeze([
|
||||
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
|
||||
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
|
||||
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
|
||||
]);
|
||||
const OID_RE = /^[0-9a-f]{40}$/;
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const MAX_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const OWNER_MAX_LIFETIME_MS = 5 * 60 * 1000;
|
||||
const OWNER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
|
||||
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
||||
const OWNER_PAYLOAD_FIELDS = Object.freeze([
|
||||
"schema_version", "provenance_id", "tenant_id", "principal_id", "task_id",
|
||||
"agent_id", "run_id", "execution_path", "request_digest", "issued_at",
|
||||
"expires_at", "key_id",
|
||||
]);
|
||||
|
||||
function normalized(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(normalized);
|
||||
const result = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
if (value[key] !== undefined) result[key] = normalized(value[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalize(value) {
|
||||
return JSON.stringify(normalized(value));
|
||||
}
|
||||
|
||||
function exactStringSet(actual, expected) {
|
||||
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
|
||||
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
|
||||
const set = new Set(actual);
|
||||
return expected.every((value) => set.has(value));
|
||||
}
|
||||
|
||||
function failure(code) {
|
||||
return { ok: false, code };
|
||||
}
|
||||
|
||||
export function validatePayload(payload, now = Date.now()) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
|
||||
const keys = Object.keys(payload).sort();
|
||||
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
|
||||
return failure("payload_schema_mismatch");
|
||||
}
|
||||
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
|
||||
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
|
||||
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
|
||||
}
|
||||
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
|
||||
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
|
||||
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
|
||||
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
|
||||
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
|
||||
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
|
||||
return failure("invalid_lifetime");
|
||||
}
|
||||
if (now < payload.issued_at) return failure("not_yet_valid");
|
||||
if (now >= payload.expires_at) return failure("expired");
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
export function privateKeyFromMaterial(material) {
|
||||
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
|
||||
if (material.length === 32) {
|
||||
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
|
||||
}
|
||||
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
|
||||
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
|
||||
}
|
||||
|
||||
export function signRecord(payload, privateMaterial, keyId) {
|
||||
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
|
||||
return {
|
||||
payload,
|
||||
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
|
||||
const signature = record.signature;
|
||||
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
|
||||
return failure("signature_contract_mismatch");
|
||||
}
|
||||
try {
|
||||
const ok = crypto.verify(
|
||||
null,
|
||||
Buffer.from(canonicalize(record.payload)),
|
||||
crypto.createPublicKey(publicKeyPem),
|
||||
Buffer.from(signature.value, "base64url"),
|
||||
);
|
||||
return ok ? { ok: true } : failure("signature_invalid");
|
||||
} catch {
|
||||
return failure("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOwnerProvenancePayload(payload, now = Date.now()) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("owner_payload_not_object");
|
||||
const keys = Object.keys(payload).sort();
|
||||
if (keys.length !== OWNER_PAYLOAD_FIELDS.length || OWNER_PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
|
||||
return failure("owner_payload_schema_mismatch");
|
||||
}
|
||||
if (payload.schema_version !== "aios.owner-provenance/v2") return failure("owner_schema_version_mismatch");
|
||||
for (const key of ["provenance_id", "tenant_id", "principal_id", "task_id", "agent_id", "run_id", "key_id"]) {
|
||||
if (typeof payload[key] !== "string" || !OWNER_ID_RE.test(payload[key])) return failure(`invalid_${key}`);
|
||||
}
|
||||
if (typeof payload.execution_path !== "string" || !payload.execution_path || payload.execution_path.length > 512) {
|
||||
return failure("invalid_execution_path");
|
||||
}
|
||||
if (typeof payload.request_digest !== "string" || !DIGEST_RE.test(payload.request_digest)) {
|
||||
return failure("invalid_request_digest");
|
||||
}
|
||||
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_owner_time");
|
||||
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > OWNER_MAX_LIFETIME_MS) {
|
||||
return failure("invalid_owner_lifetime");
|
||||
}
|
||||
if (now < payload.issued_at) return failure("owner_not_yet_valid");
|
||||
if (now >= payload.expires_at) return failure("owner_expired");
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
export function verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now = Date.now()) {
|
||||
const validated = validateOwnerProvenancePayload(record?.payload, now);
|
||||
if (!validated.ok) return validated;
|
||||
if (record.payload.key_id !== expectedKeyId) return failure("owner_key_id_mismatch");
|
||||
const signature = verifyRecord(record, publicKeyPem, expectedKeyId);
|
||||
return signature.ok ? { ok: true, payload: record.payload } : failure("owner_signature_invalid");
|
||||
}
|
||||
|
||||
function ownerRevocationPath(storeDir, provenanceId) {
|
||||
if (!path.isAbsolute(storeDir) || !OWNER_ID_RE.test(provenanceId)) throw new Error("invalid_owner_revocation_location");
|
||||
return path.join(storeDir, ".revoked", provenanceId);
|
||||
}
|
||||
|
||||
export function isOwnerProvenanceRevoked(storeDir, provenanceId) {
|
||||
return fs.existsSync(ownerRevocationPath(storeDir, provenanceId));
|
||||
}
|
||||
|
||||
export function revokeOwnerProvenance(storeDir, provenanceId, now = Date.now()) {
|
||||
const target = ownerRevocationPath(storeDir, provenanceId);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o750 });
|
||||
fs.writeFileSync(target, `${now}\n`, { mode: 0o640, flag: "wx" });
|
||||
return target;
|
||||
}
|
||||
|
||||
function readRegularFile(filePath, {
|
||||
unsafeCode,
|
||||
unsafeModeCode = unsafeCode,
|
||||
modeMask = 0o022,
|
||||
encoding,
|
||||
maxBytes,
|
||||
expectedParent,
|
||||
expectedBasename,
|
||||
} = {}) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile()) throw new Error(unsafeCode);
|
||||
if (process.platform !== "win32" && (stat.mode & modeMask) !== 0) throw new Error(unsafeModeCode);
|
||||
if (Number.isSafeInteger(maxBytes) && stat.size > maxBytes) throw new Error("provenance_record_too_large");
|
||||
if (expectedParent || expectedBasename) {
|
||||
const openedPath = process.platform === "linux"
|
||||
? fs.realpathSync(`/proc/self/fd/${descriptor}`)
|
||||
: fs.realpathSync(filePath);
|
||||
if ((expectedParent && path.dirname(openedPath) !== expectedParent)
|
||||
|| (expectedBasename && path.basename(openedPath) !== expectedBasename)) {
|
||||
throw new Error(unsafeCode);
|
||||
}
|
||||
}
|
||||
return fs.readFileSync(descriptor, encoding ? { encoding } : undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && [unsafeCode, unsafeModeCode, "provenance_record_too_large"].includes(error.message)) throw error;
|
||||
throw new Error(unsafeCode);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredRecord(storeDir, provenanceId) {
|
||||
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
|
||||
const storeReal = fs.realpathSync(storeDir);
|
||||
const target = path.join(storeReal, `${provenanceId}.json`);
|
||||
const raw = readRegularFile(target, {
|
||||
unsafeCode: "unsafe_provenance_file",
|
||||
encoding: "utf8",
|
||||
maxBytes: 128 * 1024,
|
||||
expectedParent: storeReal,
|
||||
expectedBasename: `${provenanceId}.json`,
|
||||
});
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export function readPublicKey(publicKeyPath) {
|
||||
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
|
||||
return readRegularFile(publicKeyPath, { unsafeCode: "unsafe_public_key", encoding: "utf8" });
|
||||
}
|
||||
|
||||
export function readPrivateKeyMaterial(privateKeyPath) {
|
||||
if (!path.isAbsolute(privateKeyPath)) throw new Error("invalid_private_key_path");
|
||||
return readRegularFile(privateKeyPath, {
|
||||
unsafeCode: "unsafe_private_key",
|
||||
unsafeModeCode: "unsafe_private_key_mode",
|
||||
modeMask: 0o077,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const CONTRACT_VERSION = "aios.openclaw.command.v1";
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const CLOCK_SKEW_MS = 300_000;
|
||||
const COMMAND_FIELDS = [
|
||||
"command_id", "tenant_id", "task_id", "trace_id", "action", "payload",
|
||||
"idempotency_key", "created_at", "expires_at", "contract_version", "key_id",
|
||||
];
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sorted(value) {
|
||||
if (Array.isArray(value)) return value.map(sorted);
|
||||
if (!isObject(value)) return value;
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
||||
}
|
||||
|
||||
function canonical(value) {
|
||||
return JSON.stringify(sorted(value));
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const data = Buffer.from(JSON.stringify(body));
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": String(data.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw new Error("body_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); }
|
||||
catch { throw new Error("invalid_json"); }
|
||||
if (!isObject(value)) throw new Error("invalid_json_object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireAbsolute(config, name) {
|
||||
const value = config[name];
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${name}_missing`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendRecord(statePath, record) {
|
||||
const parent = path.dirname(statePath);
|
||||
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(statePath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.chmodSync(statePath, 0o640);
|
||||
}
|
||||
|
||||
function validateCommand(value, expectedKeyId) {
|
||||
const allowed = new Set([...COMMAND_FIELDS, "signature"]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unknown_command_field");
|
||||
for (const name of COMMAND_FIELDS.filter((name) => name !== "expires_at" && name !== "payload")) {
|
||||
if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`invalid_${name}`);
|
||||
}
|
||||
if (!isObject(value.payload)) throw new Error("invalid_payload");
|
||||
if (typeof value.expires_at !== "string" || !value.expires_at) throw new Error("expires_at_required");
|
||||
if (typeof value.signature !== "string" || !value.signature) throw new Error("signature_missing");
|
||||
if (value.contract_version !== CONTRACT_VERSION) throw new Error("contract_version_rejected");
|
||||
if (value.key_id !== expectedKeyId) throw new Error("key_id_rejected");
|
||||
const created = Date.parse(value.created_at);
|
||||
const expires = Date.parse(value.expires_at);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(expires)) throw new Error("timestamp_invalid");
|
||||
if (created - Date.now() > CLOCK_SKEW_MS) throw new Error("created_at_in_future");
|
||||
if (expires <= created || expires - created > 300_000) throw new Error("expiry_window_rejected");
|
||||
if (Date.now() - expires > CLOCK_SKEW_MS) throw new Error("command_expired");
|
||||
const unsigned = Object.fromEntries(COMMAND_FIELDS.map((name) => [name, value[name]]));
|
||||
return { unsigned, expires };
|
||||
}
|
||||
|
||||
function verifySignature(command, publicKey, expectedKeyId) {
|
||||
const { unsigned, expires } = validateCommand(command, expectedKeyId);
|
||||
let signature;
|
||||
try { signature = Buffer.from(command.signature, "base64url"); }
|
||||
catch { throw new Error("signature_invalid"); }
|
||||
if (!crypto.verify(null, Buffer.from(canonical(unsigned)), publicKey, signature)) throw new Error("signature_invalid");
|
||||
return { digest: crypto.createHash("sha256").update(canonical(unsigned)).digest("hex"), expires };
|
||||
}
|
||||
|
||||
function resultFor(command, state, { output = null, error = null, startedAt, finishedAt = nowIso() } = {}) {
|
||||
return {
|
||||
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||
task_id: command.task_id, trace_id: command.trace_id, state, output, error,
|
||||
started_at: startedAt ?? nowIso(), finished_at: finishedAt,
|
||||
runtime_id: "openclaw-aios-runtime-v1", attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(statePath) {
|
||||
const commands = new Map();
|
||||
const idempotency = new Map();
|
||||
if (!fs.existsSync(statePath)) return { commands, idempotency };
|
||||
for (const line of fs.readFileSync(statePath, "utf8").split("\n").filter(Boolean)) {
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { throw new Error("runtime_state_corrupt"); }
|
||||
if (!isObject(record) || typeof record.command_id !== "string") throw new Error("runtime_state_corrupt");
|
||||
commands.set(record.command_id, record);
|
||||
if (record.tenant_id && record.idempotency_key) idempotency.set(`${record.tenant_id}\0${record.idempotency_key}`, record.command_id);
|
||||
}
|
||||
return { commands, idempotency };
|
||||
}
|
||||
|
||||
export function createRuntimeApi(api) {
|
||||
const config = api.pluginConfig ?? {};
|
||||
const statePath = requireAbsolute(config, "runtimeStatePath");
|
||||
const publicKeyPath = requireAbsolute(config, "runtimeSigningPublicKeyPath");
|
||||
const expectedKeyId = String(config.runtimeSigningKeyId || "");
|
||||
const credentialRef = String(config.runtimeCredentialRef || "");
|
||||
if (!expectedKeyId) throw new Error("config_runtimeSigningKeyId_missing");
|
||||
if (!credentialRef) throw new Error("config_runtimeCredentialRef_missing");
|
||||
const publicKey = crypto.createPublicKey(fs.readFileSync(publicKeyPath));
|
||||
const maxActive = Math.max(1, Math.min(Number(config.runtimeMaxActive || 4), 16));
|
||||
const maxDelayMs = Math.max(100, Math.min(Number(config.runtimeMaxDelayMs || 30_000), 30_000));
|
||||
const state = loadState(statePath);
|
||||
const active = new Map();
|
||||
|
||||
for (const record of state.commands.values()) {
|
||||
if (record.result?.state === "RUNNING") {
|
||||
const reconciled = { ...record, result: { ...record.result, state: "FAILED", finished_at: nowIso(), error: { code: "restart_interrupted", message: "execution outcome is unknown after runtime restart" } } };
|
||||
appendRecord(statePath, reconciled);
|
||||
state.commands.set(record.command_id, reconciled);
|
||||
}
|
||||
}
|
||||
|
||||
function requireCredential(req) {
|
||||
const supplied = String(req.headers["x-aios-credential-ref"] || "");
|
||||
const left = Buffer.from(supplied);
|
||||
const right = Buffer.from(credentialRef);
|
||||
if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) throw new Error("credential_ref_rejected");
|
||||
}
|
||||
|
||||
function persist(command, digest, result) {
|
||||
const record = {
|
||||
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||
task_id: command.task_id, trace_id: command.trace_id,
|
||||
idempotency_key: command.idempotency_key, digest, result, recorded_at: nowIso(),
|
||||
};
|
||||
appendRecord(statePath, record);
|
||||
state.commands.set(command.command_id, record);
|
||||
state.idempotency.set(`${command.tenant_id}\0${command.idempotency_key}`, command.command_id);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function execute(command, digest, expires) {
|
||||
const startedAt = nowIso();
|
||||
persist(command, digest, resultFor(command, "RUNNING", { startedAt, finishedAt: null }));
|
||||
if (command.action === "status") {
|
||||
const completed = resultFor(command, "COMPLETED", { startedAt, output: { status: "ok", runtime: "openclaw-plugin", side_effects: false } });
|
||||
persist(command, digest, completed);
|
||||
return completed;
|
||||
}
|
||||
if (command.action !== "acceptance.delay") throw new Error("unsupported_action");
|
||||
const delayMs = Number(command.payload.delay_ms);
|
||||
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > maxDelayMs) {
|
||||
const failed = resultFor(command, "FAILED", { startedAt, error: { code: "invalid_delay", message: "delay_ms is outside the allowed range" } });
|
||||
persist(command, digest, failed);
|
||||
return failed;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
active.set(command.command_id, controller);
|
||||
let timedOut = false;
|
||||
const deadlineMs = Math.max(0, Math.min(maxDelayMs, expires - Date.now()));
|
||||
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, deadlineMs);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delayMs);
|
||||
controller.signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
|
||||
});
|
||||
const completed = resultFor(command, "COMPLETED", { startedAt, output: { delayed_ms: delayMs, side_effects: false } });
|
||||
persist(command, digest, completed);
|
||||
return completed;
|
||||
} catch {
|
||||
const terminal = resultFor(command, timedOut ? "TIMED_OUT" : "CANCELLED", {
|
||||
startedAt,
|
||||
error: timedOut ? { code: "runtime_timeout", message: "runtime deadline exceeded" } : { code: "cancelled", message: "cancelled by AIOS" },
|
||||
});
|
||||
persist(command, digest, terminal);
|
||||
return terminal;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
active.delete(command.command_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecute(req, res) {
|
||||
if (req.method !== "POST") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
try {
|
||||
requireCredential(req);
|
||||
const command = await readJson(req);
|
||||
const { digest, expires } = verifySignature(command, publicKey, expectedKeyId);
|
||||
const prior = state.commands.get(command.command_id);
|
||||
if (prior) {
|
||||
if (prior.digest !== digest) return sendJson(res, 409, { error: "command_id_conflict" });
|
||||
return sendJson(res, 200, prior.result);
|
||||
}
|
||||
const idemId = state.idempotency.get(`${command.tenant_id}\0${command.idempotency_key}`);
|
||||
if (idemId) {
|
||||
const idem = state.commands.get(idemId);
|
||||
if (!idem || idem.digest !== digest) return sendJson(res, 409, { error: "idempotency_conflict" });
|
||||
return sendJson(res, 200, idem.result);
|
||||
}
|
||||
if (!new Set(["status", "acceptance.delay"]).has(command.action)) return sendJson(res, 422, { error: "unsupported_action" });
|
||||
if (active.size >= maxActive) return sendJson(res, 429, { error: "runtime_capacity_exceeded" });
|
||||
return sendJson(res, 200, await execute(command, digest, expires));
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "request_rejected";
|
||||
const status = code === "body_too_large" ? 413 : code.startsWith("config_") || code === "runtime_state_corrupt" ? 503 : 401;
|
||||
return sendJson(res, status, { error: code });
|
||||
}
|
||||
}
|
||||
|
||||
function commandRequest(req, res) {
|
||||
try {
|
||||
requireCredential(req);
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const match = url.pathname.match(/^\/v1\/commands\/([^/]+)(?:\/(status|cancel))?$/);
|
||||
if (!match) return false;
|
||||
const commandId = decodeURIComponent(match[1]);
|
||||
const operation = match[2] || "status";
|
||||
const record = state.commands.get(commandId);
|
||||
if (!record) return sendJson(res, 404, { error: "command_not_found" });
|
||||
const tenantId = String(url.searchParams.get("tenant_id") || "");
|
||||
if (!tenantId || tenantId !== record.tenant_id) return sendJson(res, 404, { error: "command_not_found" });
|
||||
if (operation === "status" && req.method === "GET") {
|
||||
return sendJson(res, 200, record.result);
|
||||
}
|
||||
if (operation === "cancel" && req.method === "POST") {
|
||||
const controller = active.get(commandId);
|
||||
if (controller) controller.abort();
|
||||
return sendJson(res, 200, { accepted: Boolean(controller), command_id: commandId });
|
||||
}
|
||||
return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
} catch (error) {
|
||||
return sendJson(res, 401, { error: error instanceof Error ? error.message : "request_rejected" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleHealth(req, res) {
|
||||
if (req.method !== "GET") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
return sendJson(res, 200, {
|
||||
runtime: "openclaw", runtime_id: "openclaw-aios-runtime-v1", status: "healthy",
|
||||
version: "2.1.0-rc.4", heartbeat_at: nowIso(), active_tasks: active.size,
|
||||
workspace_isolated: true, credential_ref_configured: true,
|
||||
details: { transport: "openclaw_plugin_http", allowed_actions: ["status", "acceptance.delay"] },
|
||||
});
|
||||
},
|
||||
handleExecute,
|
||||
commandRequest,
|
||||
};
|
||||
}
|
||||
|
||||
export const __test = { canonical, validateCommand, verifySignature, loadState };
|
||||
@@ -0,0 +1,611 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import plugin, {
|
||||
authorizeEvidenceCall,
|
||||
authorizeProtectedDispatchCall,
|
||||
consumePermit,
|
||||
consumeProtectedPermit,
|
||||
prune,
|
||||
verifyOwnerBinding,
|
||||
__state,
|
||||
} from "../index.js";
|
||||
import { classifyRun, decideConversationRun, decideToolCall, resolveSenderId, validateWriterParams } from "../policy.js";
|
||||
import {
|
||||
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, revokeOwnerProvenance,
|
||||
loadStoredRecord, readPublicKey, signRecord, validateOwnerProvenancePayload, validatePayload,
|
||||
verifyOwnerProvenanceRecord, verifyRecord,
|
||||
} from "../provenance.js";
|
||||
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
|
||||
import { issueFromFiles, issueOwnerProvenance } from "../issuer.mjs";
|
||||
|
||||
function fixture(now = Date.now()) {
|
||||
return {
|
||||
schema_version: "aios.run-provenance/v1",
|
||||
provenance_id: "prov-test-1",
|
||||
run_id: "run-test-1",
|
||||
sender_id: "owner-1",
|
||||
task_id: "task/openclaw/test",
|
||||
executor: "openclaw",
|
||||
executor_instance: "gateway-test",
|
||||
claim_oid: "a".repeat(40),
|
||||
candidate_oid: "b".repeat(40),
|
||||
task_branch: "task/openclaw/test",
|
||||
allowed_paths: [...ALLOWED_PATHS],
|
||||
allowed_operations: [...ALLOWED_OPERATIONS],
|
||||
issued_by: "AIOS_OWNER",
|
||||
issued_at: now - 1000,
|
||||
expires_at: now + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function keyFixture() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
return {
|
||||
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
|
||||
publicPem: publicKey.export({ format: "pem", type: "spki" }),
|
||||
};
|
||||
}
|
||||
|
||||
function ownerIdentity(overrides = {}) {
|
||||
return {
|
||||
tenant_id: "v2r1c2-wechat-acceptance",
|
||||
principal_id: "synthetic-owner",
|
||||
task_id: "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
|
||||
agent_id: "portal-agent",
|
||||
run_id: "run-owner-1",
|
||||
execution_path: "channel:weixin/session:synthetic-session",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function protectedParams(overrides = {}) {
|
||||
return {
|
||||
task_id: "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
|
||||
provider: "chatgpt-supervisor",
|
||||
action: "candidate.status",
|
||||
payload: { message: "synthetic acceptance" },
|
||||
idempotency_key: "synthetic-message-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Ed25519 record verifies and tampering is rejected", () => {
|
||||
const payload = fixture();
|
||||
const keys = keyFixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, true);
|
||||
record.payload.task_id = "tampered";
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, false);
|
||||
});
|
||||
|
||||
test("payload rejects expiry and set drift", () => {
|
||||
const now = Date.now();
|
||||
const expired = fixture(now);
|
||||
expired.issued_at = now - 2000;
|
||||
expired.expires_at = now - 1000;
|
||||
assert.equal(validatePayload(expired, now).code, "expired");
|
||||
const drift = fixture(now);
|
||||
drift.allowed_paths = drift.allowed_paths.slice(1);
|
||||
assert.equal(validatePayload(drift, now).code, "allowed_paths_mismatch");
|
||||
});
|
||||
|
||||
test("issuer accepts protected raw 32-byte material and refuses duplicate ids", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-issuer-"));
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const payloadPath = path.join(root, "payload.json");
|
||||
const privateKeyPath = path.join(root, "private.bin");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
const storeDir = path.join(root, "store");
|
||||
fs.writeFileSync(payloadPath, canonicalize(payload));
|
||||
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||
assert.equal(issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), payload.provenance_id);
|
||||
const record = JSON.parse(fs.readFileSync(path.join(storeDir, `${payload.provenance_id}.json`), "utf8"));
|
||||
assert.equal(verifyRecord(record, fs.readFileSync(publicKeyPath, "utf8"), "key-1").ok, true);
|
||||
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
|
||||
});
|
||||
|
||||
test("provenance and key readers reject symbolic links", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-link-"));
|
||||
const store = path.join(root, "store");
|
||||
fs.mkdirSync(store);
|
||||
const target = path.join(root, "target.json");
|
||||
fs.writeFileSync(target, "{}", { mode: 0o600 });
|
||||
const recordLink = path.join(store, "prov-link.json");
|
||||
const keyLink = path.join(root, "public-link.pem");
|
||||
fs.symlinkSync(target, recordLink);
|
||||
fs.symlinkSync(target, keyLink);
|
||||
assert.throws(() => loadStoredRecord(store, "prov-link"), /unsafe_provenance_file/);
|
||||
assert.throws(() => readPublicKey(keyLink), /unsafe_public_key/);
|
||||
});
|
||||
|
||||
test("provenance reads stay pinned to the opened descriptor during path replacement", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-race-"));
|
||||
const store = path.join(root, "store");
|
||||
fs.mkdirSync(store);
|
||||
const recordPath = path.join(store, "prov-race.json");
|
||||
fs.writeFileSync(recordPath, '{"marker":"trusted"}', { mode: 0o600 });
|
||||
const originalRead = fs.readFileSync;
|
||||
fs.readFileSync = function interceptedRead(target, ...args) {
|
||||
if (target === recordPath) {
|
||||
fs.renameSync(recordPath, `${recordPath}.trusted`);
|
||||
fs.writeFileSync(recordPath, '{"marker":"substituted"}', { mode: 0o600 });
|
||||
}
|
||||
return originalRead.call(this, target, ...args);
|
||||
};
|
||||
try {
|
||||
assert.equal(loadStoredRecord(store, "prov-race").marker, "trusted");
|
||||
} finally {
|
||||
fs.readFileSync = originalRead;
|
||||
}
|
||||
});
|
||||
|
||||
test("security caches prune expired entries and enforce a hard bound", () => {
|
||||
for (const value of Object.values(__state)) value.clear();
|
||||
const now = Date.now();
|
||||
__state.ownerUses.set("expired", now - 1);
|
||||
__state.dispatchReceipts.set("expired", { recordedAt: now - 2 * 60 * 60 * 1000 });
|
||||
__state.dispatchAttempts.set("expired", { attempts: 1, recordedAt: now - 2 * 60 * 60 * 1000 });
|
||||
for (let index = 0; index < 5000; index += 1) {
|
||||
__state.ownerUses.set(`live-${index}`, now + 60_000);
|
||||
}
|
||||
prune(now);
|
||||
assert.equal(__state.ownerUses.has("expired"), false);
|
||||
assert.equal(__state.dispatchReceipts.has("expired"), false);
|
||||
assert.equal(__state.dispatchAttempts.has("expired"), false);
|
||||
assert.equal(__state.ownerUses.size, 4096);
|
||||
});
|
||||
|
||||
test("generic mutation tools stay blocked", () => {
|
||||
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
|
||||
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin registers the native writer factory and required hook names", () => {
|
||||
const hooks = [];
|
||||
const routes = [];
|
||||
const factories = {};
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-register-"));
|
||||
const keys = keyFixture();
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem);
|
||||
const api = {
|
||||
pluginConfig: {
|
||||
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||
runtimeSigningKeyId: "key-1",
|
||||
runtimeCredentialRef: "secretref:test",
|
||||
},
|
||||
registerTool(value, options) { factories[options.name] = value; },
|
||||
registerHttpRoute(value) { routes.push(value); },
|
||||
on(name) { hooks.push(name); },
|
||||
};
|
||||
plugin.register(api);
|
||||
const tool = factories.aios_evidence_write({});
|
||||
assert.equal(tool.name, "aios_evidence_write");
|
||||
assert.equal(tool.label, "AIOS Evidence Writer");
|
||||
assert.equal(tool.executionMode, "sequential");
|
||||
const protectedTool = factories.aios_protected_dispatch({});
|
||||
assert.equal(protectedTool.name, "aios_protected_dispatch");
|
||||
assert.deepEqual(protectedTool.parameters.properties.provider.enum, ["chatgpt-supervisor"]);
|
||||
assert.deepEqual(routes.map((route) => route.path), ["/v1/health", "/v1/execute", "/v1/commands/"]);
|
||||
assert.deepEqual(hooks.sort(), ["agent_end", "before_agent_run", "before_tool_call"]);
|
||||
});
|
||||
|
||||
test("manifest explicitly declares the dedicated tool contract", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
|
||||
assert.deepEqual(manifest.contracts.tools, ["aios_evidence_write", "aios_protected_dispatch"]);
|
||||
assert.equal(manifest.configSchema.properties.allowUntrustedConversations.default, true);
|
||||
});
|
||||
|
||||
test("writer params reject unauthorized paths and operations", () => {
|
||||
const payload = fixture();
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: "README.md", operation: "update", document: {} }, payload).code, "path_not_allowed");
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "exec", document: {} }, payload).code, "operation_not_allowed");
|
||||
});
|
||||
|
||||
test("authorization binds signature, run, sender, params, and one-use permit", () => {
|
||||
__state.permits.clear();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-prov-"));
|
||||
const store = path.join(root, "store");
|
||||
fs.mkdirSync(store);
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
fs.writeFileSync(path.join(store, `${payload.provenance_id}.json`), canonicalize(record), { mode: 0o600 });
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
|
||||
const params = { provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "update", document: { state: "ok" } };
|
||||
const event = { toolName: "aios_evidence_write", toolCallId: "call-1", runId: payload.run_id, params };
|
||||
const config = {
|
||||
provenanceStoreDir: store, provenancePublicKeyPath: publicKeyPath, provenanceKeyId: "key-1",
|
||||
writerTaskBranch: payload.task_branch, writerTaskId: payload.task_id, provenanceIssuer: payload.issued_by,
|
||||
executor: payload.executor, executorInstance: payload.executor_instance,
|
||||
};
|
||||
const authorized = authorizeEvidenceCall({ event, trust: { trusted: true, senderId: payload.sender_id }, config });
|
||||
assert.equal(authorized.ok, true, authorized.code);
|
||||
assert.equal(consumePermit("call-1", params).payload.run_id, payload.run_id);
|
||||
assert.throws(() => consumePermit("call-1", params), /permit_missing/);
|
||||
const wrongRun = { ...event, toolCallId: "call-2", runId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: wrongRun, trust: { trusted: true, senderId: payload.sender_id }, config }).code, "run_id_mismatch");
|
||||
const wrongSender = { trusted: true, senderId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: { ...event, toolCallId: "call-3" }, trust: wrongSender, config }).code, "sender_id_mismatch");
|
||||
});
|
||||
|
||||
test("Owner provenance v2 binds identity, digest, expiry, revocation, and signature", () => {
|
||||
const now = Date.now();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-owner-prov-"));
|
||||
const storeDir = path.join(root, "store");
|
||||
const privateKeyPath = path.join(root, "private.bin");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
const keys = keyFixture();
|
||||
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o644 });
|
||||
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||
const params = protectedParams();
|
||||
const issued = issueOwnerProvenance({
|
||||
identity: ownerIdentity(), request: params, privateKeyPath, storeDir,
|
||||
keyId: "owner-key-1", ttlMs: 60_000, now,
|
||||
});
|
||||
const verified = verifyOwnerProvenanceRecord(issued.record, keys.publicPem, "owner-key-1", now);
|
||||
assert.equal(verified.ok, true, verified.code);
|
||||
assert.equal(validateOwnerProvenancePayload(issued.record.payload, now).ok, true);
|
||||
const expected = { ...ownerIdentity(), request_digest: issued.record.payload.request_digest };
|
||||
assert.equal(verifyOwnerBinding({
|
||||
record: issued.record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
|
||||
expected, storeDir, now,
|
||||
}).ok, true);
|
||||
|
||||
const mismatches = {
|
||||
tenant_id: "other-tenant",
|
||||
principal_id: "synthetic-user-a",
|
||||
task_id: "other-task",
|
||||
agent_id: "other-agent",
|
||||
run_id: "other-run",
|
||||
execution_path: "channel:weixin/session:other",
|
||||
request_digest: "0".repeat(64),
|
||||
};
|
||||
for (const [field, value] of Object.entries(mismatches)) {
|
||||
const record = signRecord({ ...issued.record.payload, [field]: value }, keys.privateDer, "owner-key-1");
|
||||
assert.equal(verifyOwnerBinding({
|
||||
record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
|
||||
expected, storeDir, now,
|
||||
}).code, `${field}_mismatch`);
|
||||
}
|
||||
const tampered = structuredClone(issued.record);
|
||||
tampered.payload.task_id = "other-task";
|
||||
assert.equal(verifyOwnerProvenanceRecord(tampered, keys.publicPem, "owner-key-1", now).code, "owner_signature_invalid");
|
||||
assert.equal(verifyOwnerProvenanceRecord(issued.record, keys.publicPem, "wrong-key", now).code, "owner_key_id_mismatch");
|
||||
revokeOwnerProvenance(storeDir, issued.provenanceId, now);
|
||||
assert.equal(verifyOwnerBinding({
|
||||
record: issued.record, publicKeyPem: keys.publicPem, expectedKeyId: "owner-key-1",
|
||||
expected, storeDir, now,
|
||||
}).code, "owner_revoked");
|
||||
});
|
||||
|
||||
test("sender resolution accepts only trusted OpenClaw hook identity fields", () => {
|
||||
assert.equal(resolveSenderId({}, { senderId: "weixin-owner" }), "weixin-owner");
|
||||
assert.equal(resolveSenderId({ channelContext: { sender: { id: "channel-owner" } } }, {}), "channel-owner");
|
||||
assert.equal(resolveSenderId({}, { fromUserName: "spoofed", openId: "spoofed" }), undefined);
|
||||
});
|
||||
|
||||
test("conversation classification separates message runs from identity-less system runs", () => {
|
||||
const config = { requireExplicitTrustedSenders: true, trustedSenderIds: [], blockedSenderIds: [] };
|
||||
const message = classifyRun({ prompt: "hello" }, {}, config);
|
||||
assert.equal(message.reason, "sender_id_missing");
|
||||
assert.equal(message.conversationOnly, true);
|
||||
assert.deepEqual(decideConversationRun(message, config), { action: "pass", code: "conversation_only" });
|
||||
const system = classifyRun({}, {}, config);
|
||||
assert.equal(system.conversationOnly, false);
|
||||
assert.deepEqual(decideConversationRun(system, config), { action: "block", code: "sender_id_missing" });
|
||||
});
|
||||
|
||||
test("unallowlisted senders are conversation-only and have zero tool capability", () => {
|
||||
const trust = classifyRun({ prompt: "hello", senderId: "weixin-guest" }, {}, {
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: ["weixin-owner"],
|
||||
blockedSenderIds: [],
|
||||
});
|
||||
assert.equal(trust.reason, "sender_not_allowlisted");
|
||||
assert.equal(trust.conversationOnly, true);
|
||||
assert.deepEqual(decideConversationRun(trust), { action: "pass", code: "conversation_only" });
|
||||
for (const toolName of ["read", "web_search", "aios_evidence_write", "aios_protected_dispatch", "exec"]) {
|
||||
assert.equal(decideToolCall(toolName, trust, {}).action, "block", toolName);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicitly blocked senders and strict conversation mode stay fail-closed", () => {
|
||||
const blocked = classifyRun({ prompt: "hello", senderId: "blocked-user" }, {}, {
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: [],
|
||||
blockedSenderIds: ["blocked-user"],
|
||||
});
|
||||
assert.deepEqual(decideConversationRun(blocked), { action: "block", code: "sender_blocked" });
|
||||
const unknown = classifyRun({ prompt: "hello", senderId: "unknown-user" }, {}, {
|
||||
requireExplicitTrustedSenders: true,
|
||||
trustedSenderIds: [],
|
||||
blockedSenderIds: [],
|
||||
});
|
||||
assert.deepEqual(decideConversationRun(unknown, { allowUntrustedConversations: false }), {
|
||||
action: "block", code: "sender_not_allowlisted",
|
||||
});
|
||||
});
|
||||
|
||||
function ownerPluginFixture({
|
||||
mockFailFirstDelivery = false,
|
||||
ownerTtlMs = 60_000,
|
||||
protectedDispatchProvider = "chatgpt-supervisor",
|
||||
trustedSenderIds = ["synthetic-owner", "synthetic-user-a", "synthetic-user-b"],
|
||||
} = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-owner-plugin-"));
|
||||
const keys = keyFixture();
|
||||
const privateKeyPath = path.join(root, "private.bin");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o644 });
|
||||
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||
const hooks = {};
|
||||
const factories = {};
|
||||
const api = {
|
||||
pluginConfig: {
|
||||
runtimeStatePath: path.join(root, "runtime.jsonl"),
|
||||
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||
runtimeSigningKeyId: "owner-key-1",
|
||||
runtimeCredentialRef: "secretref:test",
|
||||
provenanceStoreDir: path.join(root, "store"),
|
||||
provenancePrivateKeyPath: privateKeyPath,
|
||||
provenancePublicKeyPath: publicKeyPath,
|
||||
provenanceKeyId: "owner-key-1",
|
||||
tenantId: "v2r1c2-wechat-acceptance",
|
||||
protectedDispatchProvider,
|
||||
protectedDispatchTransport: "mock",
|
||||
mockFailFirstDelivery,
|
||||
ownerProvenanceTtlMs: ownerTtlMs,
|
||||
blockUnknownAgentRuns: true,
|
||||
requireExplicitTrustedSenders: true,
|
||||
requireSenderIsOwner: true,
|
||||
trustedSenderIds,
|
||||
blockedSenderIds: ["blocked-user"],
|
||||
},
|
||||
registerTool(factory, options) { factories[options.name] = factory; },
|
||||
registerHttpRoute() {},
|
||||
on(name, callback) { hooks[name] = callback; },
|
||||
};
|
||||
plugin.register(api);
|
||||
return { root, keys, api, hooks, factories };
|
||||
}
|
||||
|
||||
test("protected dispatch schema constrains provider to the configured value", () => {
|
||||
const fixtureValue = ownerPluginFixture({ protectedDispatchProvider: "configured-supervisor" });
|
||||
const tool = fixtureValue.factories.aios_protected_dispatch({});
|
||||
assert.deepEqual(tool.parameters.properties.provider.enum, ["configured-supervisor"]);
|
||||
});
|
||||
|
||||
test("protected dispatch binds execution and provenance to the server provider", async () => {
|
||||
for (const value of Object.values(__state)) value.clear();
|
||||
const fixtureValue = ownerPluginFixture({ protectedDispatchProvider: "chatgpt-supervisor" });
|
||||
const owner = await startSyntheticRun(fixtureValue, {
|
||||
senderId: "synthetic-owner", senderIsOwner: true, runId: "run-server-provider",
|
||||
});
|
||||
const callerParams = protectedParams({
|
||||
provider: "openclaw",
|
||||
idempotency_key: "server-provider-1",
|
||||
});
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "server-provider-call", runId: "run-server-provider", params: callerParams },
|
||||
owner.ctx,
|
||||
), undefined);
|
||||
const permit = __state.ownerPermits.get("server-provider-call");
|
||||
assert.equal(permit.request.provider, "chatgpt-supervisor");
|
||||
assert.equal(permit.record.payload.request_digest, permit.paramsDigest);
|
||||
assert.notEqual(permit.callerParamsDigest, permit.paramsDigest);
|
||||
const delivered = await fixtureValue.factories.aios_protected_dispatch({}).execute(
|
||||
"server-provider-call", callerParams,
|
||||
);
|
||||
assert.equal(delivered.details.provider, "chatgpt-supervisor");
|
||||
assert.equal(delivered.details.provider_source, "server_config");
|
||||
assert.equal(delivered.details.provider_hint_overridden, true);
|
||||
});
|
||||
|
||||
test("real-format Owner sender ids are pseudonymized before provenance issuance", async () => {
|
||||
for (const value of Object.values(__state)) value.clear();
|
||||
const realFormatOwner = "owner-fixture@im.wechat";
|
||||
const fixtureValue = ownerPluginFixture({ trustedSenderIds: [realFormatOwner] });
|
||||
const owner = await startSyntheticRun(fixtureValue, {
|
||||
senderId: realFormatOwner, senderIsOwner: false, runId: "run-pseudonymous-owner",
|
||||
});
|
||||
const params = protectedParams({ idempotency_key: "pseudonymous-owner-1" });
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "pseudonymous-owner-call", runId: "run-pseudonymous-owner", params },
|
||||
owner.ctx,
|
||||
), undefined);
|
||||
const permit = __state.ownerPermits.get("pseudonymous-owner-call");
|
||||
assert.match(permit.record.payload.principal_id, /^principal:sha256:[0-9a-f]{64}$/);
|
||||
assert.equal(permit.record.payload.principal_id.includes(realFormatOwner), false);
|
||||
});
|
||||
|
||||
async function startSyntheticRun(fixtureValue, { senderId, senderIsOwner, runId }) {
|
||||
const event = {
|
||||
prompt: "synthetic acceptance message", messages: [], accountId: "sandbox",
|
||||
channelId: "weixin", senderId, senderIsOwner,
|
||||
};
|
||||
const ctx = { runId, agentId: "portal-agent", sessionId: `session-${senderId}`, channelId: "weixin" };
|
||||
return { decision: await fixtureValue.hooks.before_agent_run(event, ctx), ctx };
|
||||
}
|
||||
|
||||
test("ordinary users and replies pass conversation gate while protected actions remain denied", async () => {
|
||||
__state.activeRuns.clear();
|
||||
const owner = ownerPluginFixture();
|
||||
const user = await startSyntheticRun(owner, { senderId: "synthetic-user-a", senderIsOwner: false, runId: "run-user-a" });
|
||||
assert.equal(user.decision.outcome, "pass");
|
||||
const params = protectedParams({ idempotency_key: "user-a-1" });
|
||||
const blocked = await owner.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "user-call", runId: "run-user-a", params },
|
||||
user.ctx,
|
||||
);
|
||||
assert.equal(blocked.block, true);
|
||||
assert.match(blocked.blockReason, /sender_not_owner/);
|
||||
const unknown = await startSyntheticRun(owner, { senderId: "unknown-user", senderIsOwner: false, runId: "run-unknown" });
|
||||
assert.equal(unknown.decision.outcome, "pass");
|
||||
for (const toolName of ["read", "web_search", "aios_evidence_write", "aios_protected_dispatch", "exec"]) {
|
||||
const denied = await owner.hooks.before_tool_call(
|
||||
{ toolName, toolCallId: `unknown-${toolName}`, runId: "run-unknown", params: {} },
|
||||
unknown.ctx,
|
||||
);
|
||||
assert.equal(denied.block, true, toolName);
|
||||
assert.match(denied.blockReason, /conversation_only_no_tools|dedicated_writer_required/);
|
||||
}
|
||||
const reply = await startSyntheticRun(owner, { senderId: "unknown-user", senderIsOwner: false, runId: "run-unknown-reply" });
|
||||
assert.equal(reply.decision.outcome, "pass");
|
||||
await owner.hooks.agent_end({}, reply.ctx);
|
||||
assert.equal(__state.activeRuns.has("run-unknown-reply"), false);
|
||||
const blockedSender = await startSyntheticRun(owner, { senderId: "blocked-user", senderIsOwner: false, runId: "run-blocked" });
|
||||
assert.equal(blockedSender.decision.outcome, "block");
|
||||
});
|
||||
|
||||
test("singleton trusted Owner binding survives a channel that omits senderIsOwner", async () => {
|
||||
__state.activeRuns.clear();
|
||||
const fixtureValue = ownerPluginFixture({ trustedSenderIds: ["synthetic-owner"] });
|
||||
const owner = await startSyntheticRun(fixtureValue, {
|
||||
senderId: "synthetic-owner",
|
||||
senderIsOwner: false,
|
||||
runId: "run-explicit-owner",
|
||||
});
|
||||
assert.equal(owner.decision.outcome, "pass");
|
||||
assert.equal(__state.activeRuns.get("run-explicit-owner").trusted, true);
|
||||
assert.equal(
|
||||
__state.activeRuns.get("run-explicit-owner").reason,
|
||||
"owner_verified_by_singleton_trusted_binding",
|
||||
);
|
||||
|
||||
const multiIdentityFixture = ownerPluginFixture();
|
||||
const nonOwner = await startSyntheticRun(multiIdentityFixture, {
|
||||
senderId: "synthetic-user-a",
|
||||
senderIsOwner: false,
|
||||
runId: "run-explicit-owner-negative",
|
||||
});
|
||||
assert.equal(nonOwner.decision.outcome, "pass");
|
||||
assert.equal(__state.activeRuns.get("run-explicit-owner-negative").trusted, false);
|
||||
assert.equal(__state.activeRuns.get("run-explicit-owner-negative").reason, "sender_not_owner");
|
||||
});
|
||||
|
||||
test("real Weixin event with omitted SenderId is conversation-only, never Owner-authenticated", async () => {
|
||||
const fixtureValue = ownerPluginFixture();
|
||||
const event = { prompt: "ordinary message", messages: [], accountId: "redacted", channelId: "openclaw-weixin" };
|
||||
const ctx = {
|
||||
runId: "run-real-shape", agentId: "portal-agent", channelId: "openclaw-weixin",
|
||||
sessionId: "redacted", sessionKey: "agent:main:openclaw-weixin:redacted:direct:redacted",
|
||||
};
|
||||
assert.deepEqual(await fixtureValue.hooks.before_agent_run(event, ctx), { outcome: "pass" });
|
||||
assert.equal(__state.activeRuns.get("run-real-shape").reason, "sender_id_missing");
|
||||
assert.equal(__state.activeRuns.get("run-real-shape").conversationOnly, true);
|
||||
const denied = await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "real-shape-call", runId: "run-real-shape", params: protectedParams() },
|
||||
ctx,
|
||||
);
|
||||
assert.equal(denied.block, true);
|
||||
assert.match(denied.blockReason, /conversation_only_no_tools/);
|
||||
});
|
||||
|
||||
test("Owner protected dispatch auto-issues provenance, retries, deduplicates, and rejects tamper/replay", async () => {
|
||||
for (const value of Object.values(__state)) value.clear();
|
||||
const fixtureValue = ownerPluginFixture({ mockFailFirstDelivery: true });
|
||||
const owner = await startSyntheticRun(fixtureValue, { senderId: "synthetic-owner", senderIsOwner: true, runId: "run-owner-1" });
|
||||
assert.equal(owner.decision.outcome, "pass");
|
||||
const tool = fixtureValue.factories.aios_protected_dispatch({});
|
||||
const params = protectedParams();
|
||||
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "call-1", runId: "run-owner-1", params }, owner.ctx,
|
||||
), undefined);
|
||||
await assert.rejects(tool.execute("call-1", params), /simulated_delivery_failure/);
|
||||
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "call-2", runId: "run-owner-1", params }, owner.ctx,
|
||||
), undefined);
|
||||
const delivered = await tool.execute("call-2", params);
|
||||
assert.equal(delivered.details.simulation, true);
|
||||
assert.equal(delivered.details.transport, "mock");
|
||||
assert.equal(delivered.details.real_user_impersonated, false);
|
||||
assert.match(delivered.details.receipt_sha256, /^[0-9a-f]{64}$/);
|
||||
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "call-3", runId: "run-owner-1", params }, owner.ctx,
|
||||
), undefined);
|
||||
const duplicate = await tool.execute("call-3", params);
|
||||
assert.equal(duplicate.details.deduplicated, true);
|
||||
assert.equal(duplicate.details.receipt_sha256, delivered.details.receipt_sha256);
|
||||
await assert.rejects(tool.execute("call-3", params), /owner_permit_missing/);
|
||||
|
||||
const tamperParams = protectedParams({ idempotency_key: "tamper-1" });
|
||||
assert.equal(await fixtureValue.hooks.before_tool_call(
|
||||
{ toolName: "aios_protected_dispatch", toolCallId: "tamper-call", runId: "run-owner-1", params: tamperParams }, owner.ctx,
|
||||
), undefined);
|
||||
await assert.rejects(
|
||||
tool.execute("tamper-call", { ...tamperParams, payload: { message: "changed" } }),
|
||||
/owner_permit_params_mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Owner permit fails closed after expiry or revocation", async () => {
|
||||
for (const value of Object.values(__state)) value.clear();
|
||||
const fixtureValue = ownerPluginFixture({ ownerTtlMs: 1000 });
|
||||
const owner = await startSyntheticRun(fixtureValue, { senderId: "synthetic-owner", senderIsOwner: true, runId: "run-owner-expiry" });
|
||||
const params = protectedParams({ idempotency_key: "expiry-1" });
|
||||
const now = Date.now();
|
||||
const trust = __state.activeRuns.get("run-owner-expiry");
|
||||
const event = { toolName: "aios_protected_dispatch", toolCallId: "expiry-call", runId: "run-owner-expiry", params };
|
||||
assert.equal(authorizeProtectedDispatchCall({ event, ctx: owner.ctx, trust, config: fixtureValue.api.pluginConfig, now }).ok, true);
|
||||
assert.throws(() => consumeProtectedPermit("expiry-call", params, now + 1500), /owner_expired/);
|
||||
|
||||
const revokedParams = protectedParams({ idempotency_key: "revoke-1" });
|
||||
const revokedEvent = { toolName: "aios_protected_dispatch", toolCallId: "revoke-call", runId: "run-owner-expiry", params: revokedParams };
|
||||
const issued = authorizeProtectedDispatchCall({ event: revokedEvent, ctx: owner.ctx, trust, config: fixtureValue.api.pluginConfig, now });
|
||||
assert.equal(issued.ok, true, issued.code);
|
||||
revokeOwnerProvenance(fixtureValue.api.pluginConfig.provenanceStoreDir, issued.provenance_id, now);
|
||||
assert.throws(() => consumeProtectedPermit("revoke-call", revokedParams, now), /owner_revoked/);
|
||||
});
|
||||
|
||||
function git(cwd, args) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
|
||||
if (result.status !== 0) throw new Error(result.stderr || "git failed");
|
||||
return (result.stdout || "").trim();
|
||||
}
|
||||
|
||||
test("writer commits only one evidence path and explicitly pushes current branch", async (t) => {
|
||||
if (spawnSync("git", ["--version"], { shell: false }).status !== 0) return t.skip("git unavailable");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-writer-"));
|
||||
const repo = path.join(root, "repo");
|
||||
const bare = path.join(root, "remote.git");
|
||||
fs.mkdirSync(repo);
|
||||
git(root, ["init", "--bare", bare]);
|
||||
git(repo, ["init", "-b", "task/openclaw/test"]);
|
||||
git(repo, ["config", "user.name", "AIOS Test"]);
|
||||
git(repo, ["config", "user.email", "aios-test@example.invalid"]);
|
||||
fs.mkdirSync(path.join(repo, ".agent"));
|
||||
fs.writeFileSync(path.join(repo, ".agent", "status.json"), "{\"state\":\"before\"}\n");
|
||||
git(repo, ["add", ".agent/status.json"]);
|
||||
git(repo, ["commit", "-m", "base"]);
|
||||
const claim = git(repo, ["rev-parse", "HEAD"]);
|
||||
git(repo, ["remote", "add", "origin", bare]);
|
||||
git(repo, ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
const payload = fixture();
|
||||
payload.claim_oid = claim;
|
||||
payload.candidate_oid = claim;
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: repo, evidencePath: ".agent/status.json", operation: "update",
|
||||
document: { state: "after" }, payload, pushRemote: "origin",
|
||||
auditLogPath: path.join(root, "audit", "writer.jsonl"), gitBinary: "git",
|
||||
});
|
||||
assert.equal(result.outcome, "pushed");
|
||||
assert.equal(git(root, ["--git-dir", bare, "rev-parse", "refs/heads/task/openclaw/test"]), result.commit_oid);
|
||||
assert.deepEqual(buildPushArgs("origin", payload.task_branch), ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { createRuntimeApi, __test } from "../runtime.js";
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-"));
|
||||
const keys = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicKey.export({ format: "pem", type: "spki" }));
|
||||
const api = createRuntimeApi({ pluginConfig: {
|
||||
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||
runtimeSigningKeyId: "runtime-key-1",
|
||||
runtimeCredentialRef: "secretref:runtime-test",
|
||||
runtimeMaxDelayMs: 3000,
|
||||
} });
|
||||
return { root, keys, api };
|
||||
}
|
||||
|
||||
function signed(keys, overrides = {}) {
|
||||
const command = {
|
||||
command_id: `cmd-${crypto.randomUUID()}`,
|
||||
tenant_id: "tenant-a",
|
||||
task_id: "task-a",
|
||||
trace_id: "trace-a",
|
||||
action: "status",
|
||||
payload: { side_effects: false },
|
||||
idempotency_key: `idem-${crypto.randomUUID()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
contract_version: "aios.openclaw.command.v1",
|
||||
key_id: "runtime-key-1",
|
||||
...overrides,
|
||||
};
|
||||
command.signature = crypto.sign(null, Buffer.from(__test.canonical(command)), keys.privateKey).toString("base64url");
|
||||
return command;
|
||||
}
|
||||
|
||||
function request(method, url, body = null) {
|
||||
const req = Readable.from(body === null ? [] : [Buffer.from(JSON.stringify(body))]);
|
||||
req.method = method;
|
||||
req.url = url;
|
||||
req.headers = { "x-aios-credential-ref": "secretref:runtime-test" };
|
||||
return req;
|
||||
}
|
||||
|
||||
function response() {
|
||||
let resolve;
|
||||
const done = new Promise((value) => { resolve = value; });
|
||||
return {
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: null,
|
||||
done,
|
||||
writeHead(status, headers) { this.status = status; this.headers = headers; },
|
||||
end(data) { this.body = JSON.parse(Buffer.from(data).toString("utf8")); resolve(this); },
|
||||
};
|
||||
}
|
||||
|
||||
async function call(handler, req) {
|
||||
const res = response();
|
||||
await handler(req, res);
|
||||
return res.done;
|
||||
}
|
||||
|
||||
test("signed status executes, persists, and replays idempotently", async () => {
|
||||
const { keys, api, root } = fixture();
|
||||
const command = signed(keys);
|
||||
const first = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.state, "COMPLETED");
|
||||
assert.equal(first.body.output.runtime, "openclaw-plugin");
|
||||
const second = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.deepEqual(second.body, first.body);
|
||||
assert.ok(fs.statSync(path.join(root, "state.jsonl")).size > 0);
|
||||
});
|
||||
|
||||
test("tamper, expiry, and conflicting identity are rejected", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const tampered = signed(keys);
|
||||
tampered.tenant_id = "tenant-b";
|
||||
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", tampered))).status, 401);
|
||||
const expired = signed(keys, {
|
||||
created_at: new Date(Date.now() - 700_000).toISOString(),
|
||||
expires_at: new Date(Date.now() - 600_000).toISOString(),
|
||||
});
|
||||
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", expired))).status, 401);
|
||||
});
|
||||
|
||||
test("status is tenant-scoped and unknown actions fail closed", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const rejected = signed(keys, { action: "arbitrary.exec" });
|
||||
const denied = await call(api.handleExecute, request("POST", "/v1/execute", rejected));
|
||||
assert.equal(denied.status, 422);
|
||||
assert.equal(denied.body.error, "unsupported_action");
|
||||
const command = signed(keys);
|
||||
await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
const wrong = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-b`));
|
||||
assert.equal(wrong.status, 404);
|
||||
const correct = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-a`));
|
||||
assert.equal(correct.body.command_id, command.command_id);
|
||||
});
|
||||
|
||||
test("active delay supports deterministic cancellation", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const command = signed(keys, { action: "acceptance.delay", payload: { delay_ms: 2000 } });
|
||||
const executeResponse = response();
|
||||
const execution = api.handleExecute(request("POST", "/v1/execute", command), executeResponse);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const cancelled = await call(api.commandRequest, request("POST", `/v1/commands/${command.command_id}/cancel?tenant_id=tenant-a`));
|
||||
assert.equal(cancelled.body.accepted, true);
|
||||
await execution;
|
||||
const terminal = await executeResponse.done;
|
||||
assert.equal(terminal.body.state, "CANCELLED");
|
||||
});
|
||||
|
||||
test("signed deadline produces TIMED_OUT", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const command = signed(keys, {
|
||||
action: "acceptance.delay",
|
||||
payload: { delay_ms: 1000 },
|
||||
expires_at: new Date(Date.now() + 40).toISOString(),
|
||||
});
|
||||
const result = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.equal(result.body.state, "TIMED_OUT");
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { ALLOWED_PATHS, canonicalize } from "./provenance.js";
|
||||
|
||||
function safeFsync(fd) {
|
||||
try { fs.fsyncSync(fd); }
|
||||
catch (error) {
|
||||
if (process.platform !== "win32" || error?.code !== "EPERM") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args, { cwd, gitBinary = "/usr/bin/git", env = {} }) {
|
||||
const result = spawnSync(gitBinary, ["-c", `safe.directory=${cwd}`, ...args], {
|
||||
cwd,
|
||||
shell: false,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
||||
});
|
||||
if (result.error || result.status !== 0) throw new Error(`git_${args[0]}_failed`);
|
||||
return result.stdout || "";
|
||||
}
|
||||
|
||||
function cleanBranch(branch) {
|
||||
const value = typeof branch === "string" ? branch.replace(/^refs\/heads\//, "") : "";
|
||||
if (!value || value.startsWith("-") || value.includes("..") || value.endsWith(".") || value.includes("@{")) {
|
||||
throw new Error("invalid_branch");
|
||||
}
|
||||
for (const forbidden of ["~", "^", ":", "?", "*", "[", "\\", " ", "\t", "\n"]) {
|
||||
if (value.includes(forbidden)) throw new Error("invalid_branch");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildPushArgs(remote, branch) {
|
||||
const clean = cleanBranch(branch);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(remote)) throw new Error("invalid_remote");
|
||||
return ["push", remote, `refs/heads/${clean}:refs/heads/${clean}`];
|
||||
}
|
||||
|
||||
function ensureSafeTarget(repoRoot, evidencePath) {
|
||||
if (!ALLOWED_PATHS.includes(evidencePath)) throw new Error("path_not_allowed");
|
||||
const root = fs.realpathSync(repoRoot);
|
||||
const target = path.resolve(root, evidencePath);
|
||||
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("path_escape");
|
||||
let cursor = root;
|
||||
for (const segment of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
||||
cursor = path.join(cursor, segment);
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error("symlink_path");
|
||||
}
|
||||
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("symlink_path");
|
||||
return { root, target };
|
||||
}
|
||||
|
||||
function atomicWrite(target, document, previousMode) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o770 });
|
||||
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
||||
const mode = previousMode ?? 0o660;
|
||||
fs.writeFileSync(temporary, `${canonicalize(document)}\n`, { flag: "wx", mode });
|
||||
const handle = fs.openSync(temporary, "r");
|
||||
try { safeFsync(handle); } finally { fs.closeSync(handle); }
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, mode);
|
||||
}
|
||||
|
||||
function appendAudit(auditLogPath, entry) {
|
||||
if (!path.isAbsolute(auditLogPath)) throw new Error("invalid_audit_log_path");
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(auditLogPath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${canonicalize(entry)}\n`);
|
||||
safeFsync(fd);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
function gitEnvironment(askPassPath) {
|
||||
if (!askPassPath) return {};
|
||||
const stat = fs.lstatSync(askPassPath);
|
||||
if (!path.isAbsolute(askPassPath) || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
|
||||
throw new Error("unsafe_askpass");
|
||||
}
|
||||
return { GIT_ASKPASS: askPassPath, GIT_ASKPASS_REQUIRE: "force", DISPLAY: "aios" };
|
||||
}
|
||||
|
||||
function restoreBeforeCommit(target, existed, backup, mode, root, relative, gitOptions) {
|
||||
try { runGit(["restore", "--staged", "--", relative], gitOptions); } catch { /* best effort */ }
|
||||
if (existed) {
|
||||
fs.writeFileSync(target, backup, { mode });
|
||||
} else if (fs.existsSync(target)) {
|
||||
fs.unlinkSync(target);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeEvidenceWrite({
|
||||
repoRoot, evidencePath, operation, document, payload, pushRemote = "gitea-http",
|
||||
askPassPath = "", auditLogPath, gitBinary = "/usr/bin/git",
|
||||
}) {
|
||||
const { root, target } = ensureSafeTarget(repoRoot, evidencePath);
|
||||
const relative = path.relative(root, target).replace(/\\/g, "/");
|
||||
const gitOptions = { cwd: root, gitBinary, env: gitEnvironment(askPassPath) };
|
||||
const branch = cleanBranch(payload.task_branch);
|
||||
if (runGit(["symbolic-ref", "--short", "HEAD"], gitOptions).trim() !== branch) throw new Error("branch_mismatch");
|
||||
if (runGit(["rev-parse", "HEAD"], gitOptions).trim() !== payload.claim_oid) throw new Error("head_mismatch");
|
||||
runGit(["merge-base", "--is-ancestor", payload.candidate_oid, payload.claim_oid], gitOptions);
|
||||
if (runGit(["status", "--porcelain", "--untracked-files=all"], gitOptions).trim()) throw new Error("worktree_not_clean");
|
||||
|
||||
const existed = fs.existsSync(target);
|
||||
if (operation === "create" && existed) throw new Error("path_exists_for_create");
|
||||
if (operation === "update" && !existed) throw new Error("path_missing_for_update");
|
||||
const backup = existed ? fs.readFileSync(target) : null;
|
||||
const priorMode = existed ? fs.statSync(target).mode & 0o777 : 0o660;
|
||||
let committed = false;
|
||||
let commitOid = null;
|
||||
try {
|
||||
atomicWrite(target, document, priorMode);
|
||||
runGit(["add", "--", relative], gitOptions);
|
||||
const staged = runGit(["diff", "--cached", "--name-only", "-z"], gitOptions).split("\0").filter(Boolean);
|
||||
if (staged.length !== 1 || staged[0] !== relative) throw new Error("staged_scope_mismatch");
|
||||
runGit(["commit", "-m", `chore(aios): provenance ${payload.provenance_id} ${path.basename(relative)}`], gitOptions);
|
||||
committed = true;
|
||||
commitOid = runGit(["rev-parse", "HEAD"], gitOptions).trim();
|
||||
runGit(buildPushArgs(pushRemote, branch), gitOptions);
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: "pushed", recorded_at: Date.now(),
|
||||
});
|
||||
return { path: relative, operation, branch, commit_oid: commitOid, outcome: "pushed" };
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "writer_failed";
|
||||
if (!committed) restoreBeforeCommit(target, existed, backup, priorMode, root, relative, gitOptions);
|
||||
try {
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: committed ? "committed_push_blocked" : "rejected",
|
||||
error_code: code, recorded_at: Date.now(),
|
||||
});
|
||||
} catch { /* the original failure remains authoritative */ }
|
||||
throw new Error(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# AIOS V2R1C2 ChatGPT Supervisor Takeover
|
||||
|
||||
- Task ID: `OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY`
|
||||
- Plan ID: `AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722`
|
||||
- Source branch: `patch/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722`
|
||||
- Source OID: `ENV:AIOS_RELEASE_SOURCE_OID`
|
||||
- Candidate service: `aios-v2r1c2-portal.service`
|
||||
- Candidate URL: `http://127.0.0.1:18921`
|
||||
- Production URL: `http://127.0.0.1:18920` (immutable during candidate work)
|
||||
|
||||
## Identity and authority
|
||||
|
||||
The root stage uses the explicit `chatgpt-supervisor` provider. It must never
|
||||
claim or serialize itself as `openclaw`. The existing OpenClaw plan remains an
|
||||
unchanged historical handoff and is not used for this takeover.
|
||||
|
||||
The provider is limited to candidate deployment, health checks, rollback,
|
||||
acceptance, WeChat simulation, and evidence production. It cannot create a
|
||||
release tag, modify `main`, or mutate the V2R1C1 production service or state.
|
||||
|
||||
## Gate
|
||||
|
||||
The stage remains blocked until every required evidence item has a verified
|
||||
SHA256, the synthetic WeChat transcript is explicitly marked as simulation,
|
||||
and both candidate and production health are rechecked after rollback drill.
|
||||
Even a passing candidate cannot be promoted without explicit Owner approval.
|
||||
@@ -0,0 +1,32 @@
|
||||
# AIOS V2R1C2 OpenClaw Online Execution
|
||||
|
||||
- Task ID: `AIOS-V2R1C2-OPENCLAW-CANDIDATE-DEPLOY-20260721`
|
||||
- Plan ID: `AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE-20260721`
|
||||
- Source branch: `patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721`
|
||||
- Source OID: `b6e714e3fcb4eb19030cbf310dbe44c5f4e478fa`
|
||||
- Execution plan: `plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json`
|
||||
- Candidate service: `aios-v2r1c2-portal.service`
|
||||
- Candidate URL: `http://127.0.0.1:18921`
|
||||
- Production URL: `http://127.0.0.1:18920` (must remain unchanged)
|
||||
|
||||
## OpenClaw action
|
||||
|
||||
Use `tools/aios_execution_plan_cli.py` to discover, register, claim, start, heartbeat and complete the OpenClaw root stage. Run `tools/aios_v2r1c2_acceptance.py --online` against the isolated candidate, submit real Evidence SHA256 values, and acknowledge real STARTED/SUCCEEDED WeChat receipts. Do not create a tag or modify production.
|
||||
|
||||
## Progress supervision
|
||||
|
||||
Report every state transition and blocker to ChatGPT with:
|
||||
|
||||
- `task_id`
|
||||
- `executor_run_id`
|
||||
- current state and step
|
||||
- latest heartbeat
|
||||
- Evidence collected or missing
|
||||
- notification delivery status
|
||||
- exact error and failing command, if any
|
||||
|
||||
ChatGPT should supervise the plan gate and prepare to intervene when the task is not registered, misses its start deadline, loses heartbeat, fails Acceptance, cannot deliver WeChat notifications, or risks touching V2R1C1 production.
|
||||
|
||||
## Completion condition
|
||||
|
||||
OpenClaw stage is complete only when Execution Truth is `SUCCEEDED`, all required Evidence hashes exist, STARTED and SUCCEEDED notifications are `DELIVERED`, and the OpenClaw stage gate passes. The overall V2R1C2 release remains blocked until Hermes, Codex and Owner approval complete.
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -13,6 +15,7 @@ if str(APP_ROOT) not in sys.path:
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesWorker
|
||||
from aios_openclaw import (
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
InProcessRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
@@ -25,10 +28,14 @@ from aios_v2 import (
|
||||
TaskState,
|
||||
TenantIsolationError,
|
||||
)
|
||||
from aios_v2.dispatch_store import DispatchState, SQLiteDispatchRepository
|
||||
from aios_v2.gateway import GatewayApplication
|
||||
from aios_v2.app import build_application
|
||||
from aios_v2.persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
|
||||
|
||||
SIGNING_MATERIAL = b"candidate-test-signing-material-0001"
|
||||
OPENCLAW_KEY_ID = "candidate-test-ed25519"
|
||||
OPENCLAW_PRIVATE_SEED = bytes(range(32))
|
||||
|
||||
|
||||
def context(
|
||||
@@ -64,6 +71,64 @@ def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
|
||||
|
||||
|
||||
class CandidateFlowTests(unittest.TestCase):
|
||||
def test_rollback_entrypoints_are_executable_in_release_archive(self) -> None:
|
||||
for relative in (
|
||||
"deploy/aios-v2/rollback/rollback.sh",
|
||||
"deploy/aios-v2/healthcheck/check.sh",
|
||||
):
|
||||
mode = (REPO_ROOT / relative).stat().st_mode
|
||||
self.assertTrue(mode & stat.S_IXUSR, relative)
|
||||
|
||||
def test_rollback_waits_for_bounded_service_readiness(self) -> None:
|
||||
script = (REPO_ROOT / "deploy/aios-v2/rollback/rollback.sh").read_text()
|
||||
self.assertIn("AIOS_V2_ROLLBACK_HEALTH_RETRIES", script)
|
||||
self.assertIn("run_healthcheck", script)
|
||||
self.assertIn("HEALTH_RETRY_DELAY", script)
|
||||
|
||||
def test_build_application_propagates_openclaw_signing_key_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
material_path = Path(temp_dir) / "signing-material.bin"
|
||||
material_path.write_bytes(OPENCLAW_PRIVATE_SEED)
|
||||
material_path.chmod(0o600)
|
||||
values = {
|
||||
"AIOS_V2_STATE_DIR": temp_dir,
|
||||
"AIOS_OPENCLAW_URL": "http://127.0.0.1:18789",
|
||||
"AIOS_OPENCLAW_SIGNING_MATERIAL_FILE": str(material_path),
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID": "openclaw-v2r1-candidate-ed25519",
|
||||
"AIOS_OPENCLAW_CREDENTIAL_REF": "secretref:test",
|
||||
}
|
||||
previous = {key: os.environ.get(key) for key in values}
|
||||
try:
|
||||
os.environ.update(values)
|
||||
application = build_application()
|
||||
finally:
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
self.assertEqual(
|
||||
application.orchestrator.openclaw_key_id,
|
||||
"openclaw-v2r1-candidate-ed25519",
|
||||
)
|
||||
|
||||
def test_http_identity_headers_are_case_insensitive(self) -> None:
|
||||
request_context = GatewayApplication.context_from_headers(
|
||||
{
|
||||
"X-Aios-Authenticated": "true",
|
||||
"x-aios-tenant-id": "tenant-a",
|
||||
"X-AIOS-PRINCIPAL-ID": "operator-a",
|
||||
"X-Aios-Roles": "operator,auditor",
|
||||
"x-aios-trace-id": "trace-http-header-case",
|
||||
}
|
||||
)
|
||||
self.assertEqual(request_context.tenant_id, "tenant-a")
|
||||
self.assertEqual(request_context.principal_id, "operator-a")
|
||||
self.assertEqual(request_context.trace_id, "trace-http-header-case")
|
||||
self.assertEqual(
|
||||
request_context.roles, frozenset({"operator", "auditor"})
|
||||
)
|
||||
|
||||
def test_openclaw_full_flow_reconciles_completed_task(self) -> None:
|
||||
service = ControlPlaneService()
|
||||
_, operator = bootstrap(service)
|
||||
@@ -88,25 +153,35 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
|
||||
signer = Ed25519CommandSigner.from_bytes(
|
||||
OPENCLAW_PRIVATE_SEED,
|
||||
key_id=OPENCLAW_KEY_ID,
|
||||
)
|
||||
transport = InProcessRuntimeTransport(
|
||||
lambda command: {
|
||||
"state": CommandState.COMPLETED.value,
|
||||
"output": {"echo": command.payload["message"]},
|
||||
}
|
||||
)
|
||||
resolver = lambda tenant_id, key_id: SIGNING_MATERIAL
|
||||
adapter = OpenClawAdapter(transport, resolver)
|
||||
adapter = OpenClawAdapter(
|
||||
transport,
|
||||
lambda tenant_id, key_id: signer.verifier(),
|
||||
)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
openclaw=adapter,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_signing_material_resolver=(
|
||||
lambda tenant_id, key_id: signer
|
||||
),
|
||||
openclaw_key_id=OPENCLAW_KEY_ID,
|
||||
)
|
||||
|
||||
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
|
||||
completed = service.get_task(operator, task.task_id)
|
||||
|
||||
self.assertEqual(dispatch.runtime, "openclaw")
|
||||
self.assertEqual(dispatch.state, CommandState.COMPLETED.value)
|
||||
self.assertEqual(dispatch.state, DispatchState.COMPLETED)
|
||||
self.assertEqual(dispatch.principal_id, operator.principal_id)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"echo": "hello"})
|
||||
|
||||
@@ -139,7 +214,7 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
orchestrator = ExecutionOrchestrator(service, hermes=hermes)
|
||||
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
|
||||
self.assertEqual(dispatch.runtime, "hermes")
|
||||
self.assertEqual(dispatch.state, HermesJobState.QUEUED.value)
|
||||
self.assertEqual(dispatch.state, DispatchState.QUEUED)
|
||||
|
||||
worker = HermesWorker(
|
||||
hermes,
|
||||
@@ -155,10 +230,98 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
self.assertEqual(run.state, HermesJobState.COMPLETED.value)
|
||||
|
||||
result = hermes.result(dispatch.runtime_execution_id, tenant_id="tenant-a")
|
||||
completed = orchestrator.reconcile_hermes(operator, result)
|
||||
completed = orchestrator.reconcile_hermes(
|
||||
operator,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=dispatch.provenance_id,
|
||||
run_id=dispatch.run_id,
|
||||
)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"transformed": 14})
|
||||
|
||||
def test_dispatch_and_control_plane_recover_before_hermes_reconcile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
control_path = root / "control-plane.sqlite3"
|
||||
dispatch_path = root / "dispatches.sqlite3"
|
||||
service = DurableControlPlaneService(SQLiteSnapshotRepository(control_path))
|
||||
_, operator = bootstrap(service)
|
||||
agent = service.register_agent(
|
||||
operator,
|
||||
agent_id="recoverable-hermes-agent",
|
||||
display_name="Recoverable Hermes",
|
||||
runtime="hermes",
|
||||
capabilities=("transform",),
|
||||
metadata={"cell_id": "cell-a", "profile_id": "profile-a"},
|
||||
)
|
||||
task = service.create_task(
|
||||
operator,
|
||||
agent_id=agent.agent_id,
|
||||
action="transform",
|
||||
payload={"value": 11},
|
||||
idempotency_key="dispatch-recovery-1",
|
||||
)
|
||||
approved = service.transition_task(
|
||||
operator,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.APPROVED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
hermes = HermesAdapter(default_lease_seconds=10)
|
||||
first = ExecutionOrchestrator(
|
||||
service,
|
||||
hermes=hermes,
|
||||
dispatch_repository=SQLiteDispatchRepository(dispatch_path),
|
||||
)
|
||||
dispatch = first.dispatch(operator, task_id=approved.task_id)
|
||||
|
||||
worker = HermesWorker(
|
||||
hermes,
|
||||
lambda job: {"transformed": int(job.payload["value"]) * 3},
|
||||
worker_id="worker-recovery",
|
||||
tenant_id="tenant-a",
|
||||
cell_id="cell-a",
|
||||
profile_id="profile-a",
|
||||
heartbeat_seconds=0.05,
|
||||
)
|
||||
run = worker.run_once()
|
||||
self.assertTrue(run.claimed)
|
||||
|
||||
recovered_service = DurableControlPlaneService(
|
||||
SQLiteSnapshotRepository(control_path)
|
||||
)
|
||||
recovered_repository = SQLiteDispatchRepository(dispatch_path)
|
||||
restored_dispatch = recovered_repository.get(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
self.assertEqual(restored_dispatch.runtime_execution_id, dispatch.runtime_execution_id)
|
||||
self.assertEqual(restored_dispatch.principal_id, operator.principal_id)
|
||||
|
||||
recovered = ExecutionOrchestrator(
|
||||
recovered_service,
|
||||
hermes=hermes,
|
||||
dispatch_repository=recovered_repository,
|
||||
)
|
||||
result = hermes.result(dispatch.runtime_execution_id, tenant_id="tenant-a")
|
||||
completed = recovered.reconcile_hermes(
|
||||
operator,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=dispatch.provenance_id,
|
||||
run_id=dispatch.run_id,
|
||||
)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"transformed": 33})
|
||||
self.assertEqual(
|
||||
recovered_repository.get(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id="tenant-a",
|
||||
).state,
|
||||
DispatchState.COMPLETED,
|
||||
)
|
||||
|
||||
def test_cross_tenant_task_read_is_denied(self) -> None:
|
||||
service = ControlPlaneService()
|
||||
_, tenant_a = bootstrap(service, "tenant-a")
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_ROOT = REPO_ROOT / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from aios_v2.app import CandidateHTTPServer, build_application
|
||||
|
||||
|
||||
class HermesLiveLifecycleTests(unittest.TestCase):
|
||||
"""Exercise the real HTTP, SQLite dispatch and Hermes lifecycle boundary."""
|
||||
|
||||
tenant_id = "tenant-hermes-live"
|
||||
principal_id = "operator-hermes-live"
|
||||
trace_id = "trace-hermes-live"
|
||||
provenance_id = "provenance-hermes-live"
|
||||
run_id = "run-hermes-live"
|
||||
cell_id = "cell-hermes-live"
|
||||
profile_id = "profile-hermes-live"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self._saved_environment = {
|
||||
name: os.environ.get(name)
|
||||
for name in (
|
||||
"AIOS_V2_STATE_DIR",
|
||||
"AIOS_HERMES_LIFECYCLE_API_ENABLED",
|
||||
"AIOS_REQUIRE_RUN_PROVENANCE",
|
||||
"AIOS_OPENCLAW_URL",
|
||||
"AIOS_OPENCLAW_SIGNING_MATERIAL_FILE",
|
||||
)
|
||||
}
|
||||
os.environ["AIOS_V2_STATE_DIR"] = self.temporary.name
|
||||
os.environ["AIOS_HERMES_LIFECYCLE_API_ENABLED"] = "1"
|
||||
os.environ["AIOS_REQUIRE_RUN_PROVENANCE"] = "0"
|
||||
os.environ.pop("AIOS_OPENCLAW_URL", None)
|
||||
os.environ.pop("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", None)
|
||||
self._start_server()
|
||||
self._bootstrap()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._stop_server()
|
||||
for name, value in self._saved_environment.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _start_server(self) -> None:
|
||||
self.application = build_application()
|
||||
self.server = CandidateHTTPServer(("127.0.0.1", 0), self.application)
|
||||
self.port = int(self.server.server_address[1])
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _stop_server(self) -> None:
|
||||
if getattr(self, "server", None) is None:
|
||||
return
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
self.server = None
|
||||
|
||||
def _restart_server(self) -> None:
|
||||
self._stop_server()
|
||||
self._start_server()
|
||||
|
||||
def _headers(self, **overrides: str) -> dict[str, str]:
|
||||
values = {
|
||||
"X-AIOS-Authenticated": "true",
|
||||
"X-AIOS-Tenant-ID": self.tenant_id,
|
||||
"X-AIOS-Principal-ID": self.principal_id,
|
||||
"X-AIOS-Trace-ID": self.trace_id,
|
||||
"X-AIOS-Roles": (
|
||||
"platform_admin,tenant_admin,operator,approver,auditor,runtime"
|
||||
),
|
||||
"X-AIOS-Provenance-ID": self.provenance_id,
|
||||
"X-AIOS-Run-ID": self.run_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, object] | None = None,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
encoded = json.dumps(body or {}, separators=(",", ":")).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{self.port}{path}",
|
||||
data=encoded if method == "POST" else None,
|
||||
method=method,
|
||||
headers=headers or self._headers(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
def _bootstrap(self) -> None:
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/tenants",
|
||||
{"tenant_id": self.tenant_id, "display_name": "Hermes Live"},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/agents",
|
||||
{
|
||||
"agent_id": "hermes-live-agent",
|
||||
"display_name": "Hermes Live",
|
||||
"runtime": "hermes",
|
||||
"capabilities": ["transform"],
|
||||
"metadata": {
|
||||
"cell_id": self.cell_id,
|
||||
"profile_id": self.profile_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def _dispatch(self, suffix: str) -> dict[str, object]:
|
||||
status, created = self._request(
|
||||
"POST",
|
||||
"/api/v2/tasks",
|
||||
{
|
||||
"agent_id": "hermes-live-agent",
|
||||
"action": "transform",
|
||||
"payload": {"value": suffix},
|
||||
"idempotency_key": f"hermes-live-{suffix}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
task = created["task"]
|
||||
assert isinstance(task, dict)
|
||||
task_id = str(task["task_id"])
|
||||
status, approved = self._request(
|
||||
"POST",
|
||||
f"/api/v2/tasks/{task_id}/approve",
|
||||
{"expected_revision": task["revision"]},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
approved_task = approved["task"]
|
||||
assert isinstance(approved_task, dict)
|
||||
status, response = self._request(
|
||||
"POST",
|
||||
f"/api/v2/tasks/{task_id}/dispatch",
|
||||
{
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 202)
|
||||
dispatch = response["dispatch"]
|
||||
assert isinstance(dispatch, dict)
|
||||
return dispatch
|
||||
|
||||
def _identity(self, dispatch: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"dispatch_id": dispatch["dispatch_id"],
|
||||
"job_id": dispatch["runtime_execution_id"],
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": dispatch["task_id"],
|
||||
"trace_id": self.trace_id,
|
||||
"principal_id": self.principal_id,
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
"cell_id": self.cell_id,
|
||||
"profile_id": self.profile_id,
|
||||
}
|
||||
|
||||
def _lifecycle(
|
||||
self,
|
||||
action: str,
|
||||
identity: dict[str, object],
|
||||
**values: object,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
body = dict(identity)
|
||||
body.update(values)
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v2/internal/runtime/hermes/lifecycle/{action}",
|
||||
body,
|
||||
)
|
||||
|
||||
def _claim_start(self, suffix: str, worker_id: str = "worker-a"):
|
||||
dispatch = self._dispatch(suffix)
|
||||
identity = self._identity(dispatch)
|
||||
status, claimed = self._lifecycle(
|
||||
"claim", identity, worker_id=worker_id, lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
job = claimed["job"]
|
||||
assert isinstance(job, dict)
|
||||
status, started = self._lifecycle(
|
||||
"start",
|
||||
identity,
|
||||
worker_id=worker_id,
|
||||
expected_revision=job["revision"],
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
return dispatch, identity, started["job"]
|
||||
|
||||
def test_01_claim(self) -> None:
|
||||
dispatch = self._dispatch("claim")
|
||||
status, payload = self._lifecycle(
|
||||
"claim", self._identity(dispatch), worker_id="worker-claim"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "CLAIMED")
|
||||
self.assertEqual(payload["dispatch"]["expected_worker_id"], "worker-claim")
|
||||
|
||||
def test_02_start(self) -> None:
|
||||
_, _, job = self._claim_start("start")
|
||||
self.assertEqual(job["state"], "RUNNING")
|
||||
|
||||
def test_03_heartbeat(self) -> None:
|
||||
_, identity, job = self._claim_start("heartbeat")
|
||||
status, payload = self._lifecycle(
|
||||
"heartbeat", identity, worker_id="worker-a", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertGreater(payload["job"]["revision"], job["revision"])
|
||||
|
||||
def test_04_complete(self) -> None:
|
||||
_, identity, _ = self._claim_start("complete")
|
||||
status, payload = self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"ok": True}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["dispatch"]["state"], "COMPLETED")
|
||||
|
||||
def test_05_fail(self) -> None:
|
||||
_, identity, _ = self._claim_start("fail")
|
||||
status, payload = self._lifecycle(
|
||||
"fail", identity, worker_id="worker-a", error={"code": "expected"}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "FAILED")
|
||||
|
||||
def test_06_cancel(self) -> None:
|
||||
dispatch = self._dispatch("cancel")
|
||||
identity = self._identity(dispatch)
|
||||
runtime_only = self._headers(**{"X-AIOS-Roles": "runtime"})
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/internal/runtime/hermes/lifecycle/cancel",
|
||||
{**identity, "cancellation_source": "aios-control-plane"},
|
||||
headers=runtime_only,
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, _ = self._lifecycle(
|
||||
"cancel",
|
||||
identity,
|
||||
worker_id="worker-must-not-cancel",
|
||||
cancellation_source="aios-control-plane",
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, payload = self._lifecycle(
|
||||
"cancel", identity, cancellation_source="aios-control-plane"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "CANCELLED")
|
||||
|
||||
def test_07_lease_expiry(self) -> None:
|
||||
dispatch = self._dispatch("expiry")
|
||||
identity = self._identity(dispatch)
|
||||
status, _ = self._lifecycle(
|
||||
"claim", identity, worker_id="worker-expired", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
time.sleep(5.1)
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "QUEUED")
|
||||
self.assertIsNone(payload["job"]["assigned_worker_id"])
|
||||
|
||||
def test_08_requeue_to_new_worker(self) -> None:
|
||||
dispatch = self._dispatch("requeue")
|
||||
identity = self._identity(dispatch)
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"claim", identity, worker_id="worker-old", lease_seconds=5
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
time.sleep(5.1)
|
||||
status, payload = self._lifecycle(
|
||||
"claim", identity, worker_id="worker-new", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["assigned_worker_id"], "worker-new")
|
||||
self.assertEqual(payload["dispatch"]["expected_worker_id"], "worker-new")
|
||||
|
||||
def test_09_crash_recovery_preserves_running_job(self) -> None:
|
||||
_, identity, _ = self._claim_start("crash")
|
||||
self._restart_server()
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "RUNNING")
|
||||
self.assertEqual(payload["dispatch"]["state"], "QUEUED")
|
||||
self.assertEqual(payload["task"]["state"], "RUNNING")
|
||||
|
||||
def test_10_cell_and_profile_isolation(self) -> None:
|
||||
dispatch = self._dispatch("isolation")
|
||||
identity = self._identity(dispatch)
|
||||
for field in ("cell_id", "profile_id"):
|
||||
bad = dict(identity)
|
||||
bad[field] = f"wrong-{field}"
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(
|
||||
self._lifecycle("claim", bad, worker_id="worker-a")[0], 400
|
||||
)
|
||||
self.assertEqual(self._lifecycle("status", identity)[1]["job"]["state"], "QUEUED")
|
||||
|
||||
def test_11_wrong_lease_owner(self) -> None:
|
||||
dispatch = self._dispatch("wrong-owner")
|
||||
identity = self._identity(dispatch)
|
||||
self.assertEqual(
|
||||
self._lifecycle("claim", identity, worker_id="worker-owner")[0], 200
|
||||
)
|
||||
status, _ = self._lifecycle("start", identity, worker_id="worker-other")
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_12_mismatched_identity_tuple(self) -> None:
|
||||
dispatch = self._dispatch("identity")
|
||||
identity = self._identity(dispatch)
|
||||
for field in (
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"principal_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"dispatch_id",
|
||||
"job_id",
|
||||
):
|
||||
bad = dict(identity)
|
||||
bad[field] = f"wrong-{field}"
|
||||
with self.subTest(field=field):
|
||||
status, _ = self._lifecycle("status", bad)
|
||||
self.assertIn(status, {400, 404, 409})
|
||||
|
||||
def test_13_same_terminal_result_is_idempotent(self) -> None:
|
||||
_, identity, _ = self._claim_start("same-terminal")
|
||||
values = {"worker_id": "worker-a", "output": {"stable": True}}
|
||||
first = self._lifecycle("complete", identity, **values)
|
||||
second = self._lifecycle("complete", identity, **values)
|
||||
self.assertEqual(first[0], 200)
|
||||
self.assertEqual(second[0], 200)
|
||||
self.assertEqual(first[1]["result"], second[1]["result"])
|
||||
|
||||
def test_14_different_terminal_result_conflicts(self) -> None:
|
||||
_, identity, _ = self._claim_start("different-terminal")
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"v": 1}
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
status, _ = self._lifecycle(
|
||||
"fail", identity, worker_id="worker-a", error={"code": "different"}
|
||||
)
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_15_full_state_consistency(self) -> None:
|
||||
_, identity, _ = self._claim_start("consistency")
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"ok": True}
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["dispatch"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["result"]["state"], "COMPLETED")
|
||||
status, executions = self._request("GET", "/api/v2/executions")
|
||||
self.assertEqual(status, 200)
|
||||
matching = [
|
||||
item
|
||||
for item in executions["executions"]
|
||||
if item["execution_id"] == identity["job_id"]
|
||||
]
|
||||
self.assertEqual(len(matching), 1)
|
||||
self.assertEqual(matching[0]["status"], "completed")
|
||||
self.assertEqual(matching[0]["provenance_id"], self.provenance_id)
|
||||
self.assertEqual(matching[0]["run_id"], self.run_id)
|
||||
status, audit = self._request("GET", "/api/v2/audit")
|
||||
self.assertEqual(status, 200)
|
||||
actions = {
|
||||
item["action"]
|
||||
for item in audit["events"]
|
||||
if item["resource_id"] == identity["job_id"]
|
||||
}
|
||||
self.assertTrue({"HERMES_CLAIM", "HERMES_START", "HERMES_COMPLETE"} <= actions)
|
||||
|
||||
def test_16_restart_then_reconcile(self) -> None:
|
||||
_, identity, _ = self._claim_start("restart-reconcile")
|
||||
self._restart_server()
|
||||
status, payload = self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"recovered": True}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
|
||||
def test_17_lifecycle_api_disabled_fail_closed(self) -> None:
|
||||
dispatch = self._dispatch("disabled")
|
||||
identity = self._identity(dispatch)
|
||||
self._stop_server()
|
||||
os.environ["AIOS_HERMES_LIFECYCLE_API_ENABLED"] = "0"
|
||||
self._start_server()
|
||||
status, _ = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_hermes import HermesAdapter
|
||||
from aios_v2 import (
|
||||
ControlPlaneService,
|
||||
Ed25519PrivateKeySigner,
|
||||
ExecutionEvidence,
|
||||
ExecutionOrchestrator,
|
||||
ExecutionPath,
|
||||
ExecutionTracker,
|
||||
GitExecutionContext,
|
||||
GitOperation,
|
||||
OperationContext,
|
||||
OrchestrationError,
|
||||
PRODUCTION_POLICY_VERSION,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceGitError,
|
||||
ProvenanceGitWriter,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceOperationGate,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RequestContext,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
TaskState,
|
||||
digest_payload,
|
||||
)
|
||||
|
||||
|
||||
class ProvenanceTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.store_path = self.root / "provenance.sqlite3"
|
||||
self.store = SQLiteProvenanceStore(self.store_path)
|
||||
self.signer = Ed25519PrivateKeySigner(
|
||||
Ed25519PrivateKey.generate(),
|
||||
key_id="owner-ed25519-test",
|
||||
)
|
||||
self.verifier = self.signer.public_verifier()
|
||||
self.issuer = ProvenanceIssuer(self.signer, self.store)
|
||||
self.writer = ProvenanceControlledWriter(
|
||||
ProvenanceVerifier(self.store, {self.verifier.key_id: self.verifier}),
|
||||
self.root / "receipts",
|
||||
)
|
||||
self.context = OperationContext(
|
||||
provenance_id="placeholder",
|
||||
run_id="run-001",
|
||||
tenant_id="tenant-a",
|
||||
task_id="task-001",
|
||||
agent_id="agent-001",
|
||||
principal_id="owner-001",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
try:
|
||||
self.store.close()
|
||||
finally:
|
||||
self.temp.cleanup()
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: int = 3600,
|
||||
now: datetime | None = None,
|
||||
allowed_git_operations: tuple[str, ...] | None = None,
|
||||
) -> RunProvenance:
|
||||
provenance = self.issuer.issue(
|
||||
run_id=self.context.run_id,
|
||||
issuer="aios-owner",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
allowed_git_operations=(
|
||||
allowed_git_operations
|
||||
if allowed_git_operations is not None
|
||||
else tuple(item.value for item in GitOperation)
|
||||
),
|
||||
ttl_seconds=ttl_seconds,
|
||||
now=now,
|
||||
)
|
||||
self.context = replace(self.context, provenance_id=provenance.provenance_id)
|
||||
return provenance
|
||||
|
||||
def authorization_values(self, request: dict[str, object]) -> dict[str, str]:
|
||||
return {
|
||||
"provenance_id": self.context.provenance_id,
|
||||
"run_id": self.context.run_id,
|
||||
"tenant_id": self.context.tenant_id,
|
||||
"task_id": self.context.task_id,
|
||||
"agent_id": self.context.agent_id,
|
||||
"principal_id": self.context.principal_id,
|
||||
"request_digest": digest_payload(request),
|
||||
}
|
||||
|
||||
def test_issue_verify_use_and_restart_recovery(self) -> None:
|
||||
provenance = self.issue()
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.ISSUED
|
||||
)
|
||||
receipt = ProvenanceOperationGate(self.writer).authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value,
|
||||
operation="candidate-deploy",
|
||||
request={"release": "v2r1-candidate", "target": "staging"},
|
||||
)
|
||||
self.assertTrue(receipt.authorization_id.startswith("pauth_"))
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.USED
|
||||
)
|
||||
self.assertEqual(self.store.authorization_count(provenance.provenance_id), 1)
|
||||
self.store.close()
|
||||
self.store = SQLiteProvenanceStore(self.store_path)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.USED
|
||||
)
|
||||
self.assertEqual(self.store.authorization_count(provenance.provenance_id), 1)
|
||||
event_types = [
|
||||
item["event_type"] for item in self.store.events(provenance.provenance_id)
|
||||
]
|
||||
self.assertIn("issued", event_types)
|
||||
self.assertIn("verified", event_types)
|
||||
self.assertIn("used", event_types)
|
||||
|
||||
def test_tampered_payload_is_rejected(self) -> None:
|
||||
provenance = self.issue()
|
||||
tampered = replace(
|
||||
provenance,
|
||||
provenance_id="prov_tampered",
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
self.store.save_issued(tampered)
|
||||
with self.assertRaises(ProvenanceSignatureError):
|
||||
self.writer.authorize_deployment(
|
||||
provenance_id=tampered.provenance_id,
|
||||
run_id=tampered.run_id,
|
||||
tenant_id=tampered.tenant_id,
|
||||
task_id=tampered.task_id,
|
||||
agent_id=tampered.agent_id,
|
||||
principal_id=tampered.principal_id,
|
||||
request_digest=digest_payload({"operation": "tamper-test"}),
|
||||
)
|
||||
|
||||
def test_expired_provenance_is_marked_and_rejected(self) -> None:
|
||||
issued_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||
provenance = self.issue(ttl_seconds=1, now=issued_at)
|
||||
verifier = ProvenanceVerifier(
|
||||
self.store, {self.verifier.key_id: self.verifier}
|
||||
)
|
||||
with self.assertRaises(ProvenanceExpired):
|
||||
verifier.authorize(
|
||||
provenance_id=provenance.provenance_id,
|
||||
run_id=provenance.run_id,
|
||||
tenant_id=provenance.tenant_id,
|
||||
task_id=provenance.task_id,
|
||||
agent_id=provenance.agent_id,
|
||||
principal_id=provenance.principal_id,
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value,
|
||||
request_digest=digest_payload({"operation": "expired"}),
|
||||
now=issued_at + timedelta(seconds=2),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.EXPIRED
|
||||
)
|
||||
|
||||
def test_revoked_provenance_is_rejected(self) -> None:
|
||||
provenance = self.issue()
|
||||
self.store.revoke(
|
||||
provenance.provenance_id,
|
||||
reason="owner emergency stop",
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
self.writer.authorize_rollback(
|
||||
**self.authorization_values({"operation": "rollback"})
|
||||
)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.REVOKED
|
||||
)
|
||||
|
||||
def test_identical_authorization_replay_is_rejected(self) -> None:
|
||||
self.issue()
|
||||
values = self.authorization_values({"operation": "deploy", "release": "r1"})
|
||||
self.writer.authorize_deployment(**values)
|
||||
with self.assertRaises(ProvenanceReplay):
|
||||
self.writer.authorize_deployment(**values)
|
||||
|
||||
def test_production_policy_requires_all_seven_paths(self) -> None:
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.issuer.issue(
|
||||
run_id=self.context.run_id,
|
||||
issuer="aios-owner",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
allowed_paths=(ExecutionPath.RUNTIME_EXECUTE.value,),
|
||||
allowed_git_operations=(),
|
||||
policy_version=PRODUCTION_POLICY_VERSION,
|
||||
)
|
||||
|
||||
def test_context_mismatch_is_rejected(self) -> None:
|
||||
self.issue()
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.writer.authorize_runtime_execute(
|
||||
provenance_id=self.context.provenance_id,
|
||||
run_id=self.context.run_id,
|
||||
tenant_id="tenant-b",
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
request_digest=digest_payload({"operation": "runtime"}),
|
||||
)
|
||||
|
||||
def test_evidence_requires_provenance_and_persists_receipt(self) -> None:
|
||||
self.issue()
|
||||
tracker = ExecutionTracker(
|
||||
self.root / "evidence",
|
||||
provenance_writer=self.writer,
|
||||
require_provenance=True,
|
||||
)
|
||||
evidence = ExecutionEvidence(
|
||||
execution_id="exec-001",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
executor="openclaw",
|
||||
branch="task/openclaw/provenance-test",
|
||||
provenance_id=self.context.provenance_id,
|
||||
run_id=self.context.run_id,
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
tracker.claim(evidence)
|
||||
claimed = tracker.claim(
|
||||
evidence,
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertIsNotNone(claimed.last_authorization_id)
|
||||
heartbeat = tracker.heartbeat(
|
||||
tenant_id=self.context.tenant_id,
|
||||
execution_id=evidence.execution_id,
|
||||
status="running",
|
||||
current_step="test",
|
||||
progress_completed=1,
|
||||
progress_total=2,
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertEqual(heartbeat.status, "running")
|
||||
completed = tracker.complete(
|
||||
tenant_id=self.context.tenant_id,
|
||||
execution_id=evidence.execution_id,
|
||||
status="complete",
|
||||
test_status="passed",
|
||||
test_summary={"passed": 1, "failed": 0},
|
||||
result_summary={"result": "ok"},
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertEqual(completed.status, "complete")
|
||||
self.assertEqual(completed.progress_completed, completed.progress_total)
|
||||
|
||||
def test_runtime_dispatch_without_provenance_is_denied_before_running(self) -> None:
|
||||
self.issue()
|
||||
service = ControlPlaneService()
|
||||
request = RequestContext(
|
||||
tenant_id=self.context.tenant_id,
|
||||
principal_id=self.context.principal_id,
|
||||
roles=frozenset({"platform_admin", "tenant_admin", "operator", "approver"}),
|
||||
trace_id="trace-prov-runtime",
|
||||
session_id="session-prov-runtime",
|
||||
policy_version="policy-v1",
|
||||
)
|
||||
service.register_tenant(
|
||||
request,
|
||||
tenant_id=self.context.tenant_id,
|
||||
display_name="Tenant A",
|
||||
)
|
||||
agent = service.register_agent(
|
||||
request,
|
||||
agent_id=self.context.agent_id,
|
||||
display_name="Hermes Agent",
|
||||
runtime="hermes",
|
||||
capabilities=("build",),
|
||||
metadata={"cell_id": "cell-a", "profile_id": "profile-a"},
|
||||
)
|
||||
task = service.create_task(
|
||||
request,
|
||||
agent_id=agent.agent_id,
|
||||
action="build",
|
||||
payload={"target": "candidate"},
|
||||
idempotency_key="prov-runtime-001",
|
||||
)
|
||||
task = service.transition_task(
|
||||
request,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.APPROVED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
hermes=HermesAdapter(),
|
||||
provenance_writer=self.writer,
|
||||
require_provenance=True,
|
||||
)
|
||||
with self.assertRaises(OrchestrationError):
|
||||
orchestrator.dispatch(request, task_id=task.task_id)
|
||||
self.assertEqual(service.get_task(request, task.task_id).state.value, "APPROVED")
|
||||
|
||||
def test_git_writer_rejects_main_fetch_to_local_branch_and_unallowed_operation(self) -> None:
|
||||
self.issue(allowed_git_operations=(GitOperation.READ_REF.value,))
|
||||
repo = self.root / "repo"
|
||||
repo.mkdir()
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "init", str(repo)], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "config", "user.name", "AIOS Test"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "config", "user.email", "aios@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
(repo / "README.md").write_text("test\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "commit", "-m", "initial"], check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
writer = ProvenanceGitWriter(repo, self.writer)
|
||||
git_context = GitExecutionContext(**self.context.as_dict())
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.push(git_context, remote="origin", branch="main")
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.fetch(
|
||||
git_context,
|
||||
remote="origin",
|
||||
refspecs=("+refs/heads/x:refs/heads/x",),
|
||||
)
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.create_branch(git_context, branch="patch/test", start_point="HEAD")
|
||||
|
||||
def test_deployment_and_rollback_are_separate_authorizations(self) -> None:
|
||||
self.issue()
|
||||
gate = ProvenanceOperationGate(self.writer)
|
||||
deployment = gate.authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value,
|
||||
operation="systemd-restart",
|
||||
request={"service": "aios-v2"},
|
||||
)
|
||||
rollback = gate.authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.ROLLBACK.value,
|
||||
operation="release-rollback",
|
||||
request={"target": "previous"},
|
||||
)
|
||||
self.assertNotEqual(deployment.authorization_id, rollback.authorization_id)
|
||||
self.assertEqual(deployment.execution_path, ExecutionPath.DEPLOYMENT.value)
|
||||
self.assertEqual(rollback.execution_path, ExecutionPath.ROLLBACK.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_v2.provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceVerifier,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from aios_v2.provenance_grants import (
|
||||
GrantIssuer,
|
||||
GrantType,
|
||||
GrantVerifier,
|
||||
SQLiteGrantStore,
|
||||
WorkflowProvenanceIssuer,
|
||||
)
|
||||
from aios_v2.provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
class ProvenanceGrantIsolationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.store_file = self.root / "provenance.sqlite3"
|
||||
self.parent_store = SQLiteProvenanceStore(self.store_file)
|
||||
self.grant_store = SQLiteGrantStore(self.store_file)
|
||||
self.signer = Ed25519PrivateKeySigner(
|
||||
Ed25519PrivateKey.generate(),
|
||||
key_id="grant-isolation-key",
|
||||
)
|
||||
self.public_verifier = self.signer.public_verifier()
|
||||
self.workflow = WorkflowProvenanceIssuer(
|
||||
self.signer,
|
||||
self.parent_store,
|
||||
).issue(
|
||||
run_id="run-grant-001",
|
||||
issuer="aios-owner",
|
||||
tenant_id="tenant-a",
|
||||
task_id="task-a",
|
||||
agent_id="agent-a",
|
||||
principal_id="owner-a",
|
||||
ttl_seconds=3600,
|
||||
)
|
||||
self.grant_issuer = GrantIssuer(
|
||||
self.signer,
|
||||
self.parent_store,
|
||||
self.grant_store,
|
||||
)
|
||||
self.grant_verifier = GrantVerifier(
|
||||
self.parent_store,
|
||||
self.grant_store,
|
||||
{self.public_verifier.key_id: self.public_verifier},
|
||||
)
|
||||
self.values = {
|
||||
"provenance_id": self.workflow.provenance_id,
|
||||
"run_id": self.workflow.run_id,
|
||||
"tenant_id": self.workflow.tenant_id,
|
||||
"task_id": self.workflow.task_id,
|
||||
"agent_id": self.workflow.agent_id,
|
||||
"principal_id": self.workflow.principal_id,
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
try:
|
||||
self.grant_store.close()
|
||||
finally:
|
||||
try:
|
||||
self.parent_store.close()
|
||||
finally:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def issue_grant(
|
||||
self,
|
||||
grant_type: GrantType,
|
||||
*,
|
||||
ttl_seconds: int = 900,
|
||||
allowed_git_operations: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
):
|
||||
return self.grant_issuer.issue(
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
grant_type=grant_type,
|
||||
ttl_seconds=ttl_seconds,
|
||||
allowed_git_operations=allowed_git_operations,
|
||||
now=now,
|
||||
)
|
||||
|
||||
def writer(self, path: ExecutionPath, grant_id: str):
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
self.grant_verifier,
|
||||
self.root / "receipts",
|
||||
{path.value: grant_id},
|
||||
required_paths=(path.value,),
|
||||
writer_id=f"test-{path.value}-writer",
|
||||
)
|
||||
|
||||
def authorization_values(self, label: str) -> dict[str, str]:
|
||||
return {
|
||||
**self.values,
|
||||
"request_digest": digest_payload({"operation": label}),
|
||||
}
|
||||
|
||||
def test_parent_workflow_has_no_operational_rights(self) -> None:
|
||||
verifier = ProvenanceVerifier(
|
||||
self.parent_store,
|
||||
{self.public_verifier.key_id: self.public_verifier},
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
verifier.authorize(
|
||||
provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id=self.workflow.tenant_id,
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "parent-runtime"}),
|
||||
)
|
||||
|
||||
def test_runtime_scope_allows_runtime_and_denies_deployment(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
|
||||
authorization = writer.authorize_runtime_execute(
|
||||
**self.authorization_values("runtime-dispatch")
|
||||
)
|
||||
self.assertEqual(authorization.grant_id, grant.grant_id)
|
||||
self.assertEqual(
|
||||
authorization.execution_path,
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
)
|
||||
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_deployment(
|
||||
**self.authorization_values("forbidden-deploy")
|
||||
)
|
||||
|
||||
def test_evidence_scope_cannot_authorize_runtime(self) -> None:
|
||||
grant = self.issue_grant(GrantType.EVIDENCE)
|
||||
writer = self.writer(ExecutionPath.EVIDENCE_WRITE, grant.grant_id)
|
||||
writer.authorize_evidence_write(
|
||||
**self.authorization_values("evidence-write")
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("forbidden-runtime")
|
||||
)
|
||||
|
||||
def test_deployment_scope_cannot_authorize_rollback(self) -> None:
|
||||
grant = self.issue_grant(GrantType.DEPLOYMENT)
|
||||
writer = self.writer(ExecutionPath.DEPLOYMENT, grant.grant_id)
|
||||
writer.authorize_deployment(
|
||||
**self.authorization_values("deploy-release")
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_rollback(
|
||||
**self.authorization_values("forbidden-rollback")
|
||||
)
|
||||
|
||||
def test_git_grant_limits_individual_git_operations(self) -> None:
|
||||
grant = self.issue_grant(
|
||||
GrantType.GIT,
|
||||
allowed_git_operations=(GitOperation.READ_REF.value,),
|
||||
)
|
||||
writer = self.writer(ExecutionPath.GIT_OPERATION, grant.grant_id)
|
||||
authorization = writer.authorize_git_operation(
|
||||
git_operation=GitOperation.READ_REF.value,
|
||||
**self.authorization_values("git-read-ref"),
|
||||
)
|
||||
self.assertEqual(authorization.git_operation, GitOperation.READ_REF.value)
|
||||
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_git_operation(
|
||||
git_operation=GitOperation.PUSH.value,
|
||||
**self.authorization_values("forbidden-git-push"),
|
||||
)
|
||||
|
||||
def test_context_mismatch_is_rejected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.grant_verifier.authorize(
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id="tenant-b",
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "tenant-mismatch"}),
|
||||
)
|
||||
|
||||
def test_identical_grant_authorization_is_replay_protected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
values = self.authorization_values("same-runtime-request")
|
||||
writer.authorize_runtime_execute(**values)
|
||||
with self.assertRaises(ProvenanceReplay):
|
||||
writer.authorize_runtime_execute(**values)
|
||||
|
||||
def test_revoked_child_grant_is_rejected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
self.grant_store.revoke(
|
||||
grant.grant_id,
|
||||
reason="runtime emergency stop",
|
||||
principal_id=self.workflow.principal_id,
|
||||
)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("revoked-runtime")
|
||||
)
|
||||
|
||||
def test_revoked_parent_blocks_all_child_grants(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
self.parent_store.revoke(
|
||||
self.workflow.provenance_id,
|
||||
reason="workflow cancelled",
|
||||
principal_id=self.workflow.principal_id,
|
||||
)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("parent-revoked")
|
||||
)
|
||||
|
||||
def test_expired_child_grant_is_rejected(self) -> None:
|
||||
issued = datetime.now(timezone.utc)
|
||||
grant = self.issue_grant(
|
||||
GrantType.RUNTIME,
|
||||
ttl_seconds=1,
|
||||
now=issued,
|
||||
)
|
||||
with self.assertRaises(ProvenanceExpired):
|
||||
self.grant_verifier.authorize(
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id=self.workflow.tenant_id,
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "expired-runtime"}),
|
||||
now=issued + timedelta(seconds=2),
|
||||
)
|
||||
|
||||
def test_scoped_writer_rejects_unnecessary_grant_mapping(self) -> None:
|
||||
runtime = self.issue_grant(GrantType.RUNTIME)
|
||||
deployment = self.issue_grant(GrantType.DEPLOYMENT)
|
||||
with self.assertRaises(ValueError):
|
||||
ScopedLeastPrivilegeProvenanceWriter(
|
||||
self.grant_verifier,
|
||||
self.root / "receipts-extra",
|
||||
{
|
||||
ExecutionPath.RUNTIME_EXECUTE.value: runtime.grant_id,
|
||||
ExecutionPath.DEPLOYMENT.value: deployment.grant_id,
|
||||
},
|
||||
required_paths=(ExecutionPath.RUNTIME_EXECUTE.value,),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,207 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "fixes" / "patch_openclaw_weixin_sender_identity.py"
|
||||
SPEC = importlib.util.spec_from_file_location("sender_identity_patch", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def plugin_fixture(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "plugin"
|
||||
(root / "src/messaging").mkdir(parents=True)
|
||||
(root / "dist/src/messaging").mkdir(parents=True)
|
||||
(root / "src/monitor").mkdir(parents=True)
|
||||
(root / "dist/src/monitor").mkdir(parents=True)
|
||||
(root / "package.json").write_text(json.dumps({"name": "@tencent-weixin/openclaw-weixin"}), encoding="utf-8")
|
||||
(root / "src/messaging/inbound.ts").write_text(
|
||||
"const ctx = {\n From: from_user_id,\n To: from_user_id,\n};\n", encoding="utf-8"
|
||||
)
|
||||
(root / "dist/src/messaging/inbound.js").write_text(
|
||||
"const ctx = {\n From: from_user_id,\n To: from_user_id,\n};\n", encoding="utf-8"
|
||||
)
|
||||
(root / "src/messaging/process-message.ts").write_text(
|
||||
'''// AIOS Hermes image follow-up helpers end
|
||||
|
||||
export async function processOneMessage(
|
||||
) {
|
||||
await deps.channelRuntime.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
|
||||
updateLastRoute: {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "openclaw-weixin",
|
||||
to: ctx.To,
|
||||
accountId: deps.accountId,
|
||||
},
|
||||
onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
|
||||
});
|
||||
try {
|
||||
await deps.channelRuntime.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
run: () =>
|
||||
deps.channelRuntime.reply.dispatchReplyFromConfig({
|
||||
ctx: finalized,
|
||||
cfg: deps.config,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
...(replyProgressSender?.replyOptions ?? {}),
|
||||
disableBlockStreaming: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
''', encoding="utf-8"
|
||||
)
|
||||
(root / "dist/src/messaging/process-message.js").write_text(
|
||||
'''// AIOS Hermes image follow-up helpers end
|
||||
|
||||
export async function processOneMessage(full, deps) {
|
||||
await deps.channelRuntime.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
ctx: finalized,
|
||||
updateLastRoute: {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "openclaw-weixin",
|
||||
to: ctx.To,
|
||||
accountId: deps.accountId,
|
||||
},
|
||||
onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
|
||||
});
|
||||
try {
|
||||
await deps.channelRuntime.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
run: () => deps.channelRuntime.reply.dispatchReplyFromConfig({
|
||||
ctx: finalized,
|
||||
cfg: deps.config,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
...(replyProgressSender?.replyOptions ?? {}),
|
||||
disableBlockStreaming: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
catch (err) { }
|
||||
}
|
||||
''', encoding="utf-8"
|
||||
)
|
||||
(root / "src/monitor/monitor.ts").write_text(
|
||||
'''import { processOneMessage } from "../messaging/process-message.js";
|
||||
const RETRY_DELAY_MS = 2_000;
|
||||
/**
|
||||
* Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.
|
||||
*/
|
||||
async function fixture() {
|
||||
await processOneMessage(full, {
|
||||
accountId,
|
||||
config,
|
||||
channelRuntime,
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
typingTicket: cachedConfig.typingTicket,
|
||||
log: opts.runtime?.log ?? (() => {}),
|
||||
errLog,
|
||||
});
|
||||
}
|
||||
''', encoding="utf-8"
|
||||
)
|
||||
(root / "dist/src/monitor/monitor.js").write_text(
|
||||
'''import { processOneMessage } from "../messaging/process-message.js";
|
||||
const RETRY_DELAY_MS = 2_000;
|
||||
/**
|
||||
* Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.
|
||||
*/
|
||||
async function fixture() {
|
||||
await processOneMessage(full, {
|
||||
accountId,
|
||||
config,
|
||||
channelRuntime,
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
typingTicket: cachedConfig.typingTicket,
|
||||
log: opts.runtime?.log ?? (() => { }),
|
||||
errLog,
|
||||
});
|
||||
}
|
||||
''', encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_check_is_non_mutating_and_reports_all_files(tmp_path: Path):
|
||||
root = plugin_fixture(tmp_path)
|
||||
result = MODULE.patch_plugin(root, apply=False)
|
||||
assert result["status"] == "CHECKED"
|
||||
assert result["changed"] is True
|
||||
assert all(item["changed"] for item in result["files"].values())
|
||||
assert "SenderId" not in (root / "src/messaging/inbound.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_apply_updates_source_and_dist_with_a_hashable_backup(tmp_path: Path):
|
||||
root = plugin_fixture(tmp_path)
|
||||
backup = tmp_path / "backup"
|
||||
result = MODULE.patch_plugin(root, apply=True, backup_dir=backup)
|
||||
assert result["status"] == "APPLIED"
|
||||
for relative in MODULE.PATCHES:
|
||||
assert result["files"][relative]["before_sha256"] == result["files"][relative]["backup_sha256"]
|
||||
assert result["files"][relative]["after_sha256"] != result["files"][relative]["before_sha256"]
|
||||
assert "SenderId: from_user_id" in (root / "src/messaging/inbound.ts").read_text(encoding="utf-8")
|
||||
source_monitor = (root / "src/monitor/monitor.ts").read_text(encoding="utf-8")
|
||||
dist_monitor = (root / "dist/src/monitor/monitor.js").read_text(encoding="utf-8")
|
||||
assert "processOneMessageWithReplySessionRetry" in source_monitor
|
||||
assert "processOneMessageWithReplySessionRetry" in dist_monitor
|
||||
assert "resolveReplySessionInitRetryDelayMs" in source_monitor
|
||||
assert "resolveReplySessionInitRetryDelayMs" in dist_monitor
|
||||
assert "REPLY_SESSION_INIT_RETRY_DELAYS_MS" in source_monitor
|
||||
assert "REPLY_SESSION_INIT_RETRY_DELAYS_MS" in dist_monitor
|
||||
assert "}, abortSignal);" in source_monitor
|
||||
assert "}, abortSignal);" in dist_monitor
|
||||
source_process = (root / "src/messaging/process-message.ts").read_text(encoding="utf-8")
|
||||
dist_process = (root / "dist/src/messaging/process-message.js").read_text(encoding="utf-8")
|
||||
assert "dispatchWithSessionInitConflictRetry" in source_process
|
||||
assert "dispatchWithSessionInitConflictRetry" in dist_process
|
||||
assert "resolveDispatchSessionInitRetryDelayMs" in source_process
|
||||
assert "resolveDispatchSessionInitRetryDelayMs" in dist_process
|
||||
assert "const sessionMetaTasks: Promise<unknown>[] = [];" in source_process
|
||||
assert "trackSessionMetaTask: (task) => sessionMetaTasks.push(task)" in source_process
|
||||
assert "await Promise.all(sessionMetaTasks);" in source_process
|
||||
assert "const sessionMetaTasks = [];" in dist_process
|
||||
assert "trackSessionMetaTask: (task) => sessionMetaTasks.push(task)" in dist_process
|
||||
assert "await Promise.all(sessionMetaTasks);" in dist_process
|
||||
|
||||
|
||||
def test_second_check_is_idempotent(tmp_path: Path):
|
||||
root = plugin_fixture(tmp_path)
|
||||
MODULE.patch_plugin(root, apply=True, backup_dir=tmp_path / "backup")
|
||||
result = MODULE.patch_plugin(root, apply=False)
|
||||
assert result["changed"] is False
|
||||
|
||||
|
||||
def test_shape_drift_fails_closed(tmp_path: Path):
|
||||
root = plugin_fixture(tmp_path)
|
||||
(root / "src/messaging/inbound.ts").write_text("unexpected", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="target_shape_mismatch"):
|
||||
MODULE.patch_plugin(root, apply=False)
|
||||
|
||||
|
||||
def test_apply_requires_new_backup_directory(tmp_path: Path):
|
||||
root = plugin_fixture(tmp_path)
|
||||
with pytest.raises(RuntimeError, match="backup_dir_required"):
|
||||
MODULE.patch_plugin(root, apply=True)
|
||||
existing = tmp_path / "existing"
|
||||
existing.mkdir()
|
||||
with pytest.raises(FileExistsError):
|
||||
MODULE.patch_plugin(root, apply=True, backup_dir=existing)
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Acceptance-level tests for the portable executor release mechanism."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_v2.execution_plan import (
|
||||
ExecutionPlanCoordinator,
|
||||
ExecutionPlanManifest,
|
||||
ExecutionStageManifest,
|
||||
WAITING_DEPENDENCY,
|
||||
)
|
||||
from aios_v2.executor_catalog import FileExecutorCatalogStore
|
||||
from aios_v2.executor_protocol import (
|
||||
DescriptorOnlyProvider,
|
||||
ExecutorProviderDescriptor,
|
||||
default_provider_registry,
|
||||
)
|
||||
from aios_v2.executor_worker import UnifiedExecutorWorker
|
||||
from aios_v2.task_execution_registry import ExecutionTaskManifest, parse_time
|
||||
from aios_v2.official_executor_providers import official_provider_factories
|
||||
from aios_web.console import PORTAL_HTML
|
||||
|
||||
DIGEST_A = "a" * 64
|
||||
DIGEST_B = "b" * 64
|
||||
|
||||
|
||||
class EvidenceRunner:
|
||||
def run_step(self, *, stage, **_: object):
|
||||
return {stage.required_evidence[0]: DIGEST_A}
|
||||
|
||||
|
||||
class FailOnceSender:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def send(self, _notification) -> str:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError("simulated WeChat transport failure")
|
||||
return DIGEST_B
|
||||
|
||||
|
||||
class PortableExecutorReleaseTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.database = self.root / "truth.sqlite3"
|
||||
self.catalog_file = self.root / "executor-catalog.json"
|
||||
|
||||
def providers(self):
|
||||
store = FileExecutorCatalogStore(self.catalog_file)
|
||||
store.initialize_defaults()
|
||||
return store.registry(provider_factories=official_provider_factories())
|
||||
|
||||
def plan(self) -> ExecutionPlanManifest:
|
||||
return ExecutionPlanManifest(
|
||||
plan_id="test-portable-release",
|
||||
source_branch="patch/test",
|
||||
source_oid="1" * 40,
|
||||
target_version="VTEST",
|
||||
stages=(
|
||||
ExecutionStageManifest(
|
||||
stage_id="root",
|
||||
task_id="task-root",
|
||||
executor_type="openclaw",
|
||||
role="candidate_deploy",
|
||||
steps=("deploy",),
|
||||
required_evidence=("deploy_log",),
|
||||
notification_events=("STARTED", "SUCCEEDED", "FAILED"),
|
||||
),
|
||||
ExecutionStageManifest(
|
||||
stage_id="child",
|
||||
task_id="task-child",
|
||||
executor_type="hermes",
|
||||
role="runtime_validate",
|
||||
depends_on=("root",),
|
||||
steps=("validate",),
|
||||
required_evidence=("runtime_report",),
|
||||
notification_events=("STARTED", "SUCCEEDED", "FAILED"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def coordinator(self) -> ExecutionPlanCoordinator:
|
||||
return ExecutionPlanCoordinator.from_file(
|
||||
self.database,
|
||||
providers=self.providers(),
|
||||
)
|
||||
|
||||
def test_catalog_round_trip_and_tamper_detection(self) -> None:
|
||||
store = FileExecutorCatalogStore(self.catalog_file)
|
||||
snapshot = store.initialize_defaults()
|
||||
self.assertEqual("aios.executor.catalog.v1", snapshot.catalog["schema_version"])
|
||||
package = store.export_migration_package(
|
||||
source_environment="candidate",
|
||||
required_executor_types=("openclaw", "hermes", "codex"),
|
||||
)
|
||||
destination = self.root / "destination.json"
|
||||
imported = FileExecutorCatalogStore.import_migration_package(
|
||||
package,
|
||||
destination_path=destination,
|
||||
destination_environment="recovery",
|
||||
provider_factories=official_provider_factories(),
|
||||
)
|
||||
self.assertEqual(snapshot.catalog_sha256, imported.catalog_sha256)
|
||||
tampered = dict(package)
|
||||
tampered["source_environment"] = "tampered"
|
||||
with self.assertRaises(ValueError):
|
||||
FileExecutorCatalogStore.import_migration_package(
|
||||
tampered,
|
||||
destination_path=self.root / "rejected.json",
|
||||
destination_environment="recovery",
|
||||
)
|
||||
|
||||
def test_future_provider_registers_without_core_change(self) -> None:
|
||||
registry = default_provider_registry()
|
||||
descriptor = ExecutorProviderDescriptor(
|
||||
executor_type="kubernetes-job",
|
||||
adapter_version="1.0.0",
|
||||
trust_roles=("kubernetes_executor",),
|
||||
capabilities=("job", "rollout"),
|
||||
)
|
||||
registry.register(DescriptorOnlyProvider(descriptor))
|
||||
self.assertEqual(
|
||||
"kubernetes-job",
|
||||
registry.get("kubernetes-job").descriptor.executor_type,
|
||||
)
|
||||
self.assertTrue(
|
||||
registry.trusted(
|
||||
executor_type="kubernetes-job",
|
||||
roles=("kubernetes_executor",),
|
||||
)
|
||||
)
|
||||
|
||||
def test_dependency_stage_is_not_declared_early(self) -> None:
|
||||
coordinator = self.coordinator()
|
||||
coordinator.declare(self.plan())
|
||||
self.assertEqual("DECLARED", coordinator.task_coordinator.registry.get("task-root").state.value)
|
||||
with self.assertRaises(KeyError):
|
||||
coordinator.task_coordinator.registry.get("task-child")
|
||||
snapshot = coordinator.snapshot("test-portable-release")
|
||||
child = next(item for item in snapshot["stages"] if item["stage"]["stage_id"] == "child")
|
||||
self.assertEqual(WAITING_DEPENDENCY, child["truth"]["state"])
|
||||
|
||||
def test_chatgpt_supervisor_is_explicit_and_codex_is_unchanged(self) -> None:
|
||||
registry = self.providers()
|
||||
supervisor = registry.get("chatgpt-supervisor").descriptor
|
||||
self.assertEqual(("chatgpt_supervisor",), supervisor.trust_roles)
|
||||
self.assertEqual(
|
||||
{
|
||||
"acceptance",
|
||||
"deploy",
|
||||
"evidence",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
"wechat_simulation",
|
||||
},
|
||||
set(supervisor.capabilities),
|
||||
)
|
||||
self.assertFalse(supervisor.metadata["production_mutation_allowed"])
|
||||
codex = registry.get("codex").descriptor
|
||||
self.assertEqual(("codex", "codex_executor"), codex.trust_roles)
|
||||
self.assertNotIn("deploy", codex.capabilities)
|
||||
self.assertNotIn("rollback", codex.capabilities)
|
||||
|
||||
def test_takeover_plan_declares_supervisor_root_and_dependency_gate(self) -> None:
|
||||
plan_path = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json"
|
||||
plan = ExecutionPlanManifest.from_dict(
|
||||
json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
root = plan.stage("chatgpt-supervisor-candidate-deploy")
|
||||
self.assertEqual("chatgpt-supervisor", root.executor_type)
|
||||
self.assertEqual(
|
||||
"OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY", root.task_id
|
||||
)
|
||||
self.assertFalse(plan.metadata["production_mutation_allowed"])
|
||||
coordinator = self.coordinator()
|
||||
coordinator.declare(plan)
|
||||
with self.assertRaises(KeyError):
|
||||
coordinator.task_coordinator.registry.get(
|
||||
"AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260722"
|
||||
)
|
||||
gate = coordinator.dependency_gate(
|
||||
plan.plan_id, "hermes-runtime-validation"
|
||||
)
|
||||
self.assertFalse(gate["passed"])
|
||||
|
||||
def test_notification_retry_and_aggregate_gate(self) -> None:
|
||||
coordinator = self.coordinator()
|
||||
plan = self.plan()
|
||||
coordinator.declare(plan)
|
||||
sender = FailOnceSender()
|
||||
worker = UnifiedExecutorWorker(
|
||||
plan_coordinator=coordinator,
|
||||
task_coordinator=coordinator.task_coordinator,
|
||||
providers=coordinator.providers,
|
||||
executor_type="openclaw",
|
||||
executor_id="openclaw-test-worker",
|
||||
step_runner=EvidenceRunner(),
|
||||
notification_sender=sender,
|
||||
)
|
||||
result = worker.run_stage(
|
||||
plan_id=plan.plan_id,
|
||||
stage_id="root",
|
||||
executor_run_id="openclaw-run-1",
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", result.state)
|
||||
self.assertGreaterEqual(sender.calls, 3)
|
||||
gate = coordinator.task_coordinator.gate("task-root")
|
||||
self.assertTrue(gate["passed"])
|
||||
self.assertEqual(["STARTED", "SUCCEEDED"], gate["delivered_notifications"])
|
||||
activated = coordinator.activate_ready(plan.plan_id)
|
||||
self.assertEqual("task-child", activated[0]["task_id"])
|
||||
self.assertEqual("DECLARED", coordinator.task_coordinator.registry.get("task-child").state.value)
|
||||
self.assertFalse(coordinator.gate(plan.plan_id)["passed"])
|
||||
|
||||
def test_missing_evidence_blocks_success(self) -> None:
|
||||
coordinator = self.coordinator()
|
||||
coordinator.declare(self.plan())
|
||||
task = coordinator.task_coordinator
|
||||
task.register("task-root", executor_run_id="run-1")
|
||||
task.claim("task-root", executor_run_id="run-1", executor_id="worker-1")
|
||||
task.start("task-root", executor_run_id="run-1", executor_id="worker-1")
|
||||
task.heartbeat(
|
||||
"task-root",
|
||||
executor_run_id="run-1",
|
||||
executor_id="worker-1",
|
||||
current_step=1,
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
task.succeed(
|
||||
"task-root",
|
||||
executor_run_id="run-1",
|
||||
executor_id="worker-1",
|
||||
evidence={},
|
||||
)
|
||||
|
||||
def test_not_started_retry_is_audited_and_status_is_consistent(self) -> None:
|
||||
coordinator = self.coordinator()
|
||||
task = coordinator.task_coordinator
|
||||
manifest = ExecutionTaskManifest(
|
||||
task_id="task-retry-not-started",
|
||||
executor="openclaw",
|
||||
source_branch="patch/test",
|
||||
source_oid="2" * 40,
|
||||
target_version="VTEST",
|
||||
steps=("deploy",),
|
||||
start_deadline_seconds=1,
|
||||
required_evidence=("deploy_log",),
|
||||
)
|
||||
task.declare(manifest)
|
||||
time.sleep(1.1)
|
||||
expired = task.status("task-retry-not-started")
|
||||
self.assertEqual("NOT_STARTED", expired["record"]["state"])
|
||||
self.assertEqual("NOT_STARTED", expired["gate"]["state"])
|
||||
replacement = ExecutionTaskManifest.from_dict(
|
||||
{**manifest.as_dict(), "source_oid": "3" * 40}
|
||||
)
|
||||
superseded = task.declare(replacement)
|
||||
self.assertEqual("NOT_STARTED", superseded.state.value)
|
||||
self.assertEqual("3" * 40, superseded.manifest.source_oid)
|
||||
retried = task.retry_not_started(
|
||||
"task-retry-not-started",
|
||||
executor_run_id="retry-run-1",
|
||||
)
|
||||
self.assertEqual("REGISTERED", retried.state.value)
|
||||
self.assertEqual("retry-run-1", retried.executor_run_id)
|
||||
self.assertEqual({}, retried.error)
|
||||
events = task.registry.events("task-retry-not-started")
|
||||
supersede_event = next(
|
||||
item for item in events
|
||||
if item["event_type"] == "MANIFEST_SUPERSEDED"
|
||||
)
|
||||
self.assertEqual("2" * 40, supersede_event["details"]["previous_source_oid"])
|
||||
self.assertEqual("3" * 40, supersede_event["details"]["source_oid"])
|
||||
retry_event = next(item for item in events if item["event_type"] == "RETRIED")
|
||||
self.assertEqual("NOT_STARTED", retry_event["details"]["previous_state"])
|
||||
with self.assertRaises(RuntimeError):
|
||||
task.retry_not_started(
|
||||
"task-retry-not-started",
|
||||
executor_run_id="retry-run-2",
|
||||
)
|
||||
|
||||
def test_plan_source_oid_supersession_requires_not_started_root(self) -> None:
|
||||
coordinator = self.coordinator()
|
||||
plan = ExecutionPlanManifest(
|
||||
plan_id="test-plan-supersession",
|
||||
source_branch="patch/test",
|
||||
source_oid="4" * 40,
|
||||
target_version="VTEST",
|
||||
stages=(
|
||||
ExecutionStageManifest(
|
||||
stage_id="root",
|
||||
task_id="task-plan-supersession",
|
||||
executor_type="openclaw",
|
||||
role="candidate_deploy",
|
||||
steps=("deploy",),
|
||||
required_evidence=("deploy_log",),
|
||||
start_deadline_seconds=1,
|
||||
),
|
||||
),
|
||||
)
|
||||
coordinator.declare(plan)
|
||||
replacement = ExecutionPlanManifest.from_dict(
|
||||
{**plan.as_dict(), "source_oid": "5" * 40}
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
coordinator.declare(replacement)
|
||||
record = coordinator.task_coordinator.registry.get(
|
||||
"task-plan-supersession"
|
||||
)
|
||||
coordinator.task_coordinator.registry.evaluate_deadlines(
|
||||
record.task_id,
|
||||
now=parse_time(record.declared_at) + timedelta(seconds=2),
|
||||
)
|
||||
coordinator.declare(replacement)
|
||||
self.assertEqual(
|
||||
"5" * 40,
|
||||
coordinator.plan_store.get(plan.plan_id).source_oid,
|
||||
)
|
||||
updated = coordinator.task_coordinator.registry.get(record.task_id)
|
||||
self.assertEqual("5" * 40, updated.manifest.source_oid)
|
||||
events = coordinator.task_coordinator.registry.events(record.task_id)
|
||||
self.assertIn("MANIFEST_SUPERSEDED", {item["event_type"] for item in events})
|
||||
|
||||
def test_execution_center_has_no_diff_markers(self) -> None:
|
||||
self.assertIn('id="execution"', PORTAL_HTML)
|
||||
self.assertIn("async function execution()", PORTAL_HTML)
|
||||
self.assertNotIn("\n+function executionStage", PORTAL_HTML)
|
||||
self.assertNotIn("\n+async function execution", PORTAL_HTML)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Portable AIOS execution-plan and executor lifecycle CLI.
|
||||
|
||||
OpenClaw, Hermes, Codex and future Providers use this same entrypoint. The CLI
|
||||
records execution truth in the shared SQLite store; it never treats a task file,
|
||||
process exit or log line as proof of successful execution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
|
||||
from aios_v2.executor_catalog import FileExecutorCatalogStore
|
||||
from aios_v2.official_executor_providers import official_provider_factories
|
||||
|
||||
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def emit(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
|
||||
|
||||
|
||||
def load_object(path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"JSON object required: {path}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def resolve_env(value: Any, label: str) -> Any:
|
||||
if not isinstance(value, str) or not value.startswith("ENV:"):
|
||||
return value
|
||||
name = value[4:].strip()
|
||||
resolved = str(os.getenv(name, "")).strip()
|
||||
if not name or not resolved:
|
||||
raise RuntimeError(f"required environment variable is not set: {name or label}")
|
||||
return resolved
|
||||
|
||||
|
||||
def load_plan(path: str | Path) -> ExecutionPlanManifest:
|
||||
value = dict(load_object(path))
|
||||
for key in ("source_branch", "source_oid", "target_version"):
|
||||
value[key] = resolve_env(value.get(key), key)
|
||||
source_oid = str(value.get("source_oid") or "").strip().lower()
|
||||
if not _GIT_OID.fullmatch(source_oid):
|
||||
raise ValueError("source_oid must resolve to a 40-character Git OID")
|
||||
value["source_oid"] = source_oid
|
||||
return ExecutionPlanManifest.from_dict(value)
|
||||
|
||||
|
||||
def coordinator(args: argparse.Namespace) -> ExecutionPlanCoordinator:
|
||||
catalog = FileExecutorCatalogStore(args.catalog_file)
|
||||
catalog.initialize_defaults()
|
||||
providers = catalog.registry(provider_factories=official_provider_factories())
|
||||
return ExecutionPlanCoordinator.from_file(args.state_file, providers=providers)
|
||||
|
||||
|
||||
def declared(args: argparse.Namespace) -> tuple[ExecutionPlanCoordinator, ExecutionPlanManifest]:
|
||||
value = coordinator(args)
|
||||
plan = load_plan(args.plan_file)
|
||||
value.declare(plan)
|
||||
return value, plan
|
||||
|
||||
|
||||
def evidence(args: argparse.Namespace) -> Mapping[str, str]:
|
||||
raw = load_object(args.evidence_file)
|
||||
return {str(key): str(value) for key, value in raw.items()}
|
||||
|
||||
|
||||
def command_declare(args: argparse.Namespace) -> int:
|
||||
value, plan = declared(args)
|
||||
emit(value.snapshot(plan.plan_id))
|
||||
return 0
|
||||
|
||||
|
||||
def command_snapshot(args: argparse.Namespace) -> int:
|
||||
value, plan = declared(args)
|
||||
emit(value.snapshot(plan.plan_id))
|
||||
return 0
|
||||
|
||||
|
||||
def command_discover(args: argparse.Namespace) -> int:
|
||||
value, plan = declared(args)
|
||||
items = value.discover_ready(plan_id=plan.plan_id, executor_type=args.executor)
|
||||
emit({"plan_id": plan.plan_id, "executor_type": args.executor, "count": len(items), "items": items})
|
||||
return 0
|
||||
|
||||
|
||||
def command_gate(args: argparse.Namespace) -> int:
|
||||
value, plan = declared(args)
|
||||
result = value.gate(plan.plan_id)
|
||||
emit(result)
|
||||
return 0 if result["passed"] else 3
|
||||
|
||||
|
||||
def command_status(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.status(args.task_id))
|
||||
return 0
|
||||
|
||||
|
||||
def command_register(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_claim(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.claim(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_start(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.start(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_heartbeat(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.heartbeat(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, current_step=args.current_step).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_succeed(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.succeed(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, evidence=evidence(args)).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_fail(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
error = load_object(args.error_file)
|
||||
supplied = evidence(args) if args.evidence_file else None
|
||||
emit(value.task_coordinator.fail(args.task_id, executor_run_id=args.executor_run_id, executor_id=args.executor_id, error=error, evidence=supplied).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def command_notifications(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
items = value.task_coordinator.notifications.list(task_id=args.task_id, status=args.status)
|
||||
emit({"count": len(items), "items": [item.as_dict() for item in items]})
|
||||
return 0
|
||||
|
||||
|
||||
def command_ack(args: argparse.Namespace) -> int:
|
||||
value, _ = declared(args)
|
||||
emit(value.task_coordinator.notifications.acknowledge(args.notification_id, receipt_sha256=args.receipt_sha256).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def add_common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--state-file", default=os.getenv("AIOS_EXTERNAL_TASK_STORE_FILE", "/var/lib/aios-v2r1c2-portal/external-task-truth.sqlite3"))
|
||||
parser.add_argument("--catalog-file", default=os.getenv("AIOS_EXECUTOR_CATALOG_FILE", "/var/lib/aios-v2r1c2-portal/executor-catalog.json"))
|
||||
parser.add_argument("--plan-file", default=os.getenv("AIOS_EXECUTION_PLAN_FILE", str(ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json")))
|
||||
|
||||
|
||||
def add_lifecycle(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--executor-run-id", required=True)
|
||||
parser.add_argument("--executor-id", required=True)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
root = argparse.ArgumentParser(description="AIOS portable execution plan CLI")
|
||||
commands = root.add_subparsers(dest="command", required=True)
|
||||
for name, handler in (
|
||||
("declare", command_declare),
|
||||
("snapshot", command_snapshot),
|
||||
("gate", command_gate),
|
||||
):
|
||||
item = commands.add_parser(name)
|
||||
add_common(item)
|
||||
item.set_defaults(handler=handler)
|
||||
|
||||
discover = commands.add_parser("discover")
|
||||
add_common(discover)
|
||||
discover.add_argument("--executor", required=True)
|
||||
discover.set_defaults(handler=command_discover)
|
||||
|
||||
status = commands.add_parser("status")
|
||||
add_common(status)
|
||||
status.add_argument("--task-id", required=True)
|
||||
status.set_defaults(handler=command_status)
|
||||
|
||||
register = commands.add_parser("register")
|
||||
add_common(register)
|
||||
register.add_argument("--task-id", required=True)
|
||||
register.add_argument("--executor-run-id", required=True)
|
||||
register.set_defaults(handler=command_register)
|
||||
|
||||
for name, handler in (("claim", command_claim), ("start", command_start)):
|
||||
item = commands.add_parser(name)
|
||||
add_common(item)
|
||||
add_lifecycle(item)
|
||||
item.set_defaults(handler=handler)
|
||||
|
||||
heartbeat = commands.add_parser("heartbeat")
|
||||
add_common(heartbeat)
|
||||
add_lifecycle(heartbeat)
|
||||
heartbeat.add_argument("--current-step", type=int, required=True)
|
||||
heartbeat.set_defaults(handler=command_heartbeat)
|
||||
|
||||
succeed = commands.add_parser("succeed")
|
||||
add_common(succeed)
|
||||
add_lifecycle(succeed)
|
||||
succeed.add_argument("--evidence-file", required=True)
|
||||
succeed.set_defaults(handler=command_succeed)
|
||||
|
||||
fail = commands.add_parser("fail")
|
||||
add_common(fail)
|
||||
add_lifecycle(fail)
|
||||
fail.add_argument("--error-file", required=True)
|
||||
fail.add_argument("--evidence-file")
|
||||
fail.set_defaults(handler=command_fail)
|
||||
|
||||
notifications = commands.add_parser("notifications")
|
||||
add_common(notifications)
|
||||
notifications.add_argument("--task-id")
|
||||
notifications.add_argument("--status")
|
||||
notifications.set_defaults(handler=command_notifications)
|
||||
|
||||
ack = commands.add_parser("ack-notification")
|
||||
add_common(ack)
|
||||
ack.add_argument("--notification-id", required=True)
|
||||
ack.add_argument("--receipt-sha256", required=True)
|
||||
ack.set_defaults(handler=command_ack)
|
||||
return root
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.handler(args))
|
||||
except Exception as exc:
|
||||
print(json.dumps({"status": "error", "error": type(exc).__name__, "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline and online acceptance gate for the AIOS V2R1C2 portal candidate.
|
||||
|
||||
Offline mode validates source syntax, the immutable execution plan, the
|
||||
portable-executor test suite and deployment assets. Online mode additionally
|
||||
checks the isolated candidate on port 18921. The script writes a machine-readable
|
||||
Evidence report whose SHA256 can be submitted to the Execution Truth Registry.
|
||||
It never changes production, creates a tag or marks a plan stage successful.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
PLAN = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json"
|
||||
TAKEOVER_PLAN = ROOT / "plans" / "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json"
|
||||
SYSTEMD = ROOT / "deploy" / "aios-v2" / "systemd" / "aios-v2r1c2-portal.service"
|
||||
ENV_TEMPLATE = ROOT / "deploy" / "aios-v2" / "systemd" / "aios-v2r1c2-portal.env.example"
|
||||
PORTABLE_TEST = ROOT / "tests" / "web" / "test_portable_executor_release.py"
|
||||
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
return sha256_bytes(path.read_bytes())
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
name: str
|
||||
passed: bool
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"passed": self.passed,
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
class Acceptance:
|
||||
def __init__(self, *, source_oid: str, base_url: str, online: bool) -> None:
|
||||
self.source_oid = source_oid
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.online = online
|
||||
self.checks: list[Check] = []
|
||||
|
||||
def record(self, name: str, action: Callable[[], Mapping[str, Any]]) -> None:
|
||||
try:
|
||||
details = action()
|
||||
self.checks.append(Check(name=name, passed=True, details=details))
|
||||
print(f"PASS: {name}")
|
||||
except Exception as exc:
|
||||
self.checks.append(
|
||||
Check(
|
||||
name=name,
|
||||
passed=False,
|
||||
details={
|
||||
"error": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
)
|
||||
print(f"FAIL: {name}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
|
||||
def validate_source_oid(self) -> Mapping[str, Any]:
|
||||
if not _GIT_OID.fullmatch(self.source_oid):
|
||||
raise ValueError("source OID must be an exact 40-character lowercase Git SHA1")
|
||||
return {"source_oid": self.source_oid}
|
||||
|
||||
def compile_sources(self) -> Mapping[str, Any]:
|
||||
roots = (
|
||||
APP / "aios_v2",
|
||||
APP / "aios_web",
|
||||
APP / "aios_openclaw",
|
||||
APP / "aios_hermes",
|
||||
)
|
||||
files = sorted(path for root in roots for path in root.rglob("*.py"))
|
||||
if not files:
|
||||
raise RuntimeError("no Python sources discovered")
|
||||
digest = hashlib.sha256()
|
||||
for path in files:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
compile(source, str(path), "exec")
|
||||
digest.update(str(path.relative_to(ROOT)).encode("utf-8"))
|
||||
digest.update(source.encode("utf-8"))
|
||||
return {"files": len(files), "source_bundle_sha256": digest.hexdigest()}
|
||||
|
||||
def validate_plan(self) -> Mapping[str, Any]:
|
||||
os.environ["AIOS_RELEASE_SOURCE_OID"] = self.source_oid
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
from aios_web.release_application import _load_execution_plan
|
||||
from aios_v2.executor_catalog import FileExecutorCatalogStore
|
||||
from aios_v2.official_executor_providers import official_provider_factories
|
||||
|
||||
plans = [_load_execution_plan(path) for path in (PLAN, TAKEOVER_PLAN)]
|
||||
if any(plan.target_version != "V2R1C2" for plan in plans):
|
||||
raise RuntimeError("unexpected target version")
|
||||
if any(plan.source_oid != self.source_oid for plan in plans):
|
||||
raise RuntimeError("plan source OID mismatch")
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
store = FileExecutorCatalogStore(Path(temporary) / "catalog.json")
|
||||
snapshot = store.initialize_defaults()
|
||||
providers = store.registry(provider_factories=official_provider_factories())
|
||||
for plan in plans:
|
||||
for stage in plan.stages:
|
||||
providers.get(stage.executor_type)
|
||||
takeover = plans[1]
|
||||
takeover_executors = {stage.executor_type for stage in takeover.stages}
|
||||
if "chatgpt-supervisor" not in takeover_executors:
|
||||
raise RuntimeError("takeover plan is missing chatgpt-supervisor")
|
||||
if not any(
|
||||
stage.task_id == "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY"
|
||||
for stage in takeover.stages
|
||||
):
|
||||
raise RuntimeError("takeover plan is missing the authorized deployment task")
|
||||
return {
|
||||
"plan_ids": [plan.plan_id for plan in plans],
|
||||
"target_version": plans[0].target_version,
|
||||
"stage_count": sum(len(plan.stages) for plan in plans),
|
||||
"executors": sorted(
|
||||
{stage.executor_type for plan in plans for stage in plan.stages}
|
||||
),
|
||||
"plan_sha256": {
|
||||
PLAN.name: sha256_file(PLAN),
|
||||
TAKEOVER_PLAN.name: sha256_file(TAKEOVER_PLAN),
|
||||
},
|
||||
"catalog_sha256": snapshot.catalog_sha256,
|
||||
}
|
||||
|
||||
def validate_deployment_assets(self) -> Mapping[str, Any]:
|
||||
service = SYSTEMD.read_text(encoding="utf-8")
|
||||
environment = ENV_TEMPLATE.read_text(encoding="utf-8")
|
||||
required_service = (
|
||||
"User=aios",
|
||||
"AIOS_V2_PORT",
|
||||
"18921",
|
||||
"NoNewPrivileges=true",
|
||||
"ProtectSystem=strict",
|
||||
"ReadWritePaths=/var/lib/aios-v2r1c2-portal",
|
||||
"AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json",
|
||||
)
|
||||
required_environment = (
|
||||
"AIOS_V2_PORT=18921",
|
||||
"AIOS_RELEASE_SOURCE_OID=REPLACE_WITH_EXACT_40_CHARACTER_GIT_OID",
|
||||
"AIOS_EXECUTION_PLAN_FILES=",
|
||||
"AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json",
|
||||
"AIOS_OWNER_WECHAT_TARGET_REF=",
|
||||
)
|
||||
missing = [item for item in required_service if item not in service]
|
||||
missing += [item for item in required_environment if item not in environment]
|
||||
if missing:
|
||||
raise RuntimeError(f"deployment assets are missing requirements: {missing}")
|
||||
return {
|
||||
"systemd_sha256": sha256_file(SYSTEMD),
|
||||
"environment_template_sha256": sha256_file(ENV_TEMPLATE),
|
||||
}
|
||||
|
||||
def run_command(self, name: str, command: Sequence[str]) -> Mapping[str, Any]:
|
||||
process = subprocess.run(
|
||||
list(command),
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
check=False,
|
||||
)
|
||||
encoded = process.stdout.encode("utf-8")
|
||||
if process.returncode != 0:
|
||||
tail = "\n".join(process.stdout.splitlines()[-40:])
|
||||
raise RuntimeError(f"exit={process.returncode}\n{tail}")
|
||||
return {
|
||||
"command": list(command),
|
||||
"exit_code": process.returncode,
|
||||
"output_sha256": sha256_bytes(encoded),
|
||||
"output_lines": len(process.stdout.splitlines()),
|
||||
"label": name,
|
||||
}
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
authenticated: bool,
|
||||
) -> Mapping[str, Any]:
|
||||
headers: dict[str, str] = {}
|
||||
if authenticated:
|
||||
headers.update(
|
||||
{
|
||||
"X-AIOS-Authenticated": "true",
|
||||
"X-AIOS-Tenant-ID": "v2r1c2-acceptance",
|
||||
"X-AIOS-Principal-ID": "v2r1c2-acceptance",
|
||||
"X-AIOS-Roles": "platform_admin",
|
||||
"X-AIOS-Trace-ID": "v2r1c2-acceptance",
|
||||
}
|
||||
)
|
||||
request = urllib.request.Request(f"{self.base_url}{path}", headers=headers)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
if status != 200 or not isinstance(payload, Mapping):
|
||||
raise RuntimeError(f"unexpected response status={status}")
|
||||
return {
|
||||
"path": path,
|
||||
"status": status,
|
||||
"response_sha256": sha256_bytes(raw),
|
||||
"payload": dict(payload),
|
||||
}
|
||||
|
||||
def online_health(self) -> Mapping[str, Any]:
|
||||
value = self.request_json("/api/v2/health", authenticated=False)
|
||||
if value["payload"].get("status") != "ok":
|
||||
raise RuntimeError("candidate health is not ok")
|
||||
return value
|
||||
|
||||
def online_capabilities(self) -> Mapping[str, Any]:
|
||||
value = self.request_json("/api/v2/me/capabilities", authenticated=True)
|
||||
capabilities = set(value["payload"].get("capabilities") or ())
|
||||
required = {"portal.access", "external_task.read", "release.read"}
|
||||
missing = sorted(required - capabilities)
|
||||
if missing:
|
||||
raise RuntimeError(f"portal capabilities are missing: {missing}")
|
||||
value["payload"] = {
|
||||
"principal_id": value["payload"].get("principal_id"),
|
||||
"roles": value["payload"].get("roles"),
|
||||
"capability_count": len(capabilities),
|
||||
}
|
||||
return value
|
||||
|
||||
def online_execution_center(self) -> Mapping[str, Any]:
|
||||
value = self.request_json("/api/v2/portal/execution", authenticated=True)
|
||||
payload = value["payload"]
|
||||
if payload.get("protocol_version") != "aios.executor.v1":
|
||||
raise RuntimeError("executor protocol mismatch")
|
||||
plans = payload.get("plans") or ()
|
||||
if not plans:
|
||||
raise RuntimeError("candidate has no declared execution plan")
|
||||
plan_ids = {item.get("plan_id") for item in plans}
|
||||
required_plan = "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722"
|
||||
if required_plan not in plan_ids:
|
||||
raise RuntimeError("candidate is missing the takeover execution plan")
|
||||
provider_names = {
|
||||
item.get("provider") or item.get("executor_type") or item.get("name")
|
||||
for item in payload.get("providers") or ()
|
||||
}
|
||||
if "chatgpt-supervisor" not in provider_names:
|
||||
raise RuntimeError("candidate is missing the chatgpt-supervisor provider")
|
||||
value["payload"] = {
|
||||
"protocol_version": payload.get("protocol_version"),
|
||||
"plan_schema_version": payload.get("plan_schema_version"),
|
||||
"provider_count": len(payload.get("providers") or ()),
|
||||
"plan_ids": [item.get("plan_id") for item in plans],
|
||||
"gates": [item.get("gate") for item in plans],
|
||||
}
|
||||
return value
|
||||
|
||||
def run(self) -> Mapping[str, Any]:
|
||||
self.record("source_oid", self.validate_source_oid)
|
||||
self.record("python_compile", self.compile_sources)
|
||||
self.record("execution_plan", self.validate_plan)
|
||||
self.record("deployment_assets", self.validate_deployment_assets)
|
||||
self.record(
|
||||
"portable_executor_tests",
|
||||
lambda: self.run_command(
|
||||
"portable_executor_tests",
|
||||
(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"unittest",
|
||||
"discover",
|
||||
"-s",
|
||||
str(PORTABLE_TEST.parent.relative_to(ROOT)),
|
||||
"-p",
|
||||
PORTABLE_TEST.name,
|
||||
"-v",
|
||||
),
|
||||
),
|
||||
)
|
||||
self.record(
|
||||
"candidate_regression_tests",
|
||||
lambda: self.run_command(
|
||||
"candidate_regression_tests",
|
||||
(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"unittest",
|
||||
"discover",
|
||||
"-s",
|
||||
"tests/aios_v2_candidate",
|
||||
"-p",
|
||||
"test_*.py",
|
||||
"-v",
|
||||
),
|
||||
),
|
||||
)
|
||||
if self.online:
|
||||
self.record("online_health", self.online_health)
|
||||
self.record("online_capabilities", self.online_capabilities)
|
||||
self.record("online_execution_center", self.online_execution_center)
|
||||
passed = all(item.passed for item in self.checks)
|
||||
report = {
|
||||
"schema_version": "aios.acceptance-evidence.v1",
|
||||
"candidate": "V2R1C2",
|
||||
"baseline": "V2R1C1",
|
||||
"source_oid": self.source_oid,
|
||||
"online": self.online,
|
||||
"base_url": self.base_url if self.online else None,
|
||||
"generated_at": utc_now(),
|
||||
"passed": passed,
|
||||
"checks": [item.as_dict() for item in self.checks],
|
||||
}
|
||||
report["report_sha256"] = sha256_bytes(canonical_bytes(report))
|
||||
return report
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="AIOS V2R1C2 acceptance gate")
|
||||
parser.add_argument(
|
||||
"--source-oid",
|
||||
default=os.getenv("AIOS_RELEASE_SOURCE_OID", ""),
|
||||
help="exact 40-character candidate Git OID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--online",
|
||||
action="store_true",
|
||||
help="also validate the isolated candidate service",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("AIOS_V2_BASE_URL", "http://127.0.0.1:18921"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evidence-output",
|
||||
default="artifacts/v2r1c2/acceptance-evidence.json",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
report = Acceptance(
|
||||
source_oid=str(args.source_oid).strip().lower(),
|
||||
base_url=args.base_url,
|
||||
online=bool(args.online),
|
||||
).run()
|
||||
output = Path(args.evidence_output).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2))
|
||||
print(f"EVIDENCE_FILE={output}")
|
||||
print(f"EVIDENCE_SHA256={sha256_file(output)}")
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE = "http://127.0.0.1:18921"
|
||||
PREFIX = "/api/v2/internal/external-tasks"
|
||||
TASK_ID = "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY"
|
||||
EXECUTOR = "chatgpt-supervisor"
|
||||
EXECUTOR_ID = "chatgpt-supervisor-codex"
|
||||
RUN_ID = "chatgpt-supervisor-f3c00d72943f-run1"
|
||||
HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"X-AIOS-Authenticated": "true",
|
||||
"X-AIOS-Tenant-ID": "v2r1c2-supervision",
|
||||
"X-AIOS-Principal-ID": EXECUTOR_ID,
|
||||
"X-AIOS-Roles": "chatgpt_supervisor",
|
||||
"X-AIOS-Trace-ID": RUN_ID,
|
||||
}
|
||||
|
||||
|
||||
def call(path: str, *, method: str = "GET", body: object | None = None):
|
||||
data = None if body is None else json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
||||
request = urllib.request.Request(BASE + path, data=data, headers=HEADERS, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw) if raw else {}, raw
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read()
|
||||
raise RuntimeError(f"{method} {path} status={error.code} body={raw.decode('utf-8', 'replace')}") from error
|
||||
|
||||
|
||||
def post_action(action: str, extra: dict[str, object] | None = None):
|
||||
payload: dict[str, object] = {"executor_run_id": RUN_ID}
|
||||
if action != "register":
|
||||
payload["executor_id"] = EXECUTOR_ID
|
||||
payload.update(extra or {})
|
||||
return call(f"{PREFIX}/{urllib.parse.quote(TASK_ID)}/{action}", method="POST", body=payload)[1]
|
||||
|
||||
|
||||
def file_sha256(path: str | Path) -> str:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def truth():
|
||||
return call(f"{PREFIX}/{urllib.parse.quote(TASK_ID)}/truth")[1]
|
||||
|
||||
|
||||
def notifications():
|
||||
query = urllib.parse.urlencode({"executor": EXECUTOR, "task_id": TASK_ID})
|
||||
return call(f"{PREFIX}/notifications?{query}")[1]["items"]
|
||||
|
||||
|
||||
def acknowledge(event_type: str, receipt_sha256: str):
|
||||
matches = [item for item in notifications() if item["event_type"] == event_type]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"expected one {event_type} notification, got {len(matches)}")
|
||||
notification = matches[0]
|
||||
return call(
|
||||
f"{PREFIX}/notifications/{urllib.parse.quote(notification['notification_id'])}/ack",
|
||||
method="POST",
|
||||
body={"executor_id": EXECUTOR_ID, "receipt_sha256": receipt_sha256},
|
||||
)[1]
|
||||
|
||||
|
||||
def write_report(output: str, payload: dict[str, object]) -> None:
|
||||
payload["schema_version"] = "aios.v2r1c2-supervision.v1"
|
||||
payload["task_id"] = TASK_ID
|
||||
payload["executor_type"] = EXECUTOR
|
||||
payload["executor_id"] = EXECUTOR_ID
|
||||
payload["executor_run_id"] = RUN_ID
|
||||
payload["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
payload["report_sha256"] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
target = Path(output).resolve()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"EVIDENCE_FILE={target}")
|
||||
print(f"EVIDENCE_SHA256={file_sha256(target)}")
|
||||
|
||||
|
||||
def start(args) -> None:
|
||||
initial = truth()
|
||||
if initial["record"]["state"] == "DECLARED":
|
||||
post_action("register")
|
||||
post_action("claim")
|
||||
post_action("start")
|
||||
elif initial["record"]["state"] == "NOT_STARTED":
|
||||
post_action("retry")
|
||||
post_action("claim")
|
||||
post_action("start")
|
||||
elif initial["record"]["state"] != "RUNNING":
|
||||
raise RuntimeError(f"unexpected initial state {initial['record']['state']}")
|
||||
post_action("heartbeat", {"current_step": 7})
|
||||
receipt = file_sha256(args.started_receipt)
|
||||
acknowledge("STARTED", receipt)
|
||||
current = truth()
|
||||
if current["record"]["state"] != "RUNNING" or current["record"]["current_step"] != 7:
|
||||
raise RuntimeError("supervision start truth mismatch")
|
||||
write_report(args.output, {"phase": "started", "started_receipt_sha256": receipt, "truth": current})
|
||||
print("SUPERVISION_START=PASS STATE=RUNNING STEP=7 STARTED_NOTIFICATION=DELIVERED")
|
||||
|
||||
|
||||
def heartbeat(args) -> None:
|
||||
post_action("heartbeat", {"current_step": args.step})
|
||||
current = truth()
|
||||
write_report(args.output, {"phase": "heartbeat", "truth": current})
|
||||
print(f"SUPERVISION_HEARTBEAT=PASS STEP={args.step}")
|
||||
|
||||
|
||||
def succeed(args) -> None:
|
||||
evidence = json.loads(Path(args.evidence_map).read_text(encoding="utf-8"))
|
||||
if not isinstance(evidence, dict):
|
||||
raise RuntimeError("evidence map must be an object")
|
||||
post_action("heartbeat", {"current_step": 8})
|
||||
post_action("succeed", {"evidence": evidence})
|
||||
receipt = file_sha256(args.completed_receipt)
|
||||
acknowledge("SUCCEEDED", receipt)
|
||||
current = truth()
|
||||
if current["record"]["state"] != "SUCCEEDED" or not current["gate"]["passed"]:
|
||||
raise RuntimeError("terminal truth gate did not pass")
|
||||
write_report(args.output, {"phase": "succeeded", "completed_receipt_sha256": receipt, "truth": current})
|
||||
print("SUPERVISION_SUCCEED=PASS STATE=SUCCEEDED STAGE_GATE=PASS NOTIFICATION_GATE=PASS")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
start_parser = subparsers.add_parser("start")
|
||||
start_parser.add_argument("--started-receipt", required=True)
|
||||
start_parser.add_argument("--output", required=True)
|
||||
heartbeat_parser = subparsers.add_parser("heartbeat")
|
||||
heartbeat_parser.add_argument("--step", required=True, type=int)
|
||||
heartbeat_parser.add_argument("--output", required=True)
|
||||
succeed_parser = subparsers.add_parser("succeed")
|
||||
succeed_parser.add_argument("--evidence-map", required=True)
|
||||
succeed_parser.add_argument("--completed-receipt", required=True)
|
||||
succeed_parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
{"start": start, "heartbeat": heartbeat, "succeed": succeed}[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"vrc": "V2R1C1",
|
||||
"semver": "2.1.0-rc.1",
|
||||
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"release_branch": "release/AIOS-V2R1C1-20260721",
|
||||
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
|
||||
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
|
||||
"openclaw_companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||
"openclaw_plugin_version": "2.1.0-rc.4",
|
||||
"openclaw_artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671",
|
||||
"validation_status": "PASS",
|
||||
"deployment_status": "NOT_STARTED",
|
||||
"previous_oid": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
||||
}
|
||||
Reference in New Issue
Block a user