Compare commits

..
Author SHA1 Message Date
AIOS Candidate Integration 54e7dac80a security: authorize candidate owner signer callers 2026-07-26 00:23:48 +08:00
AIOS Candidate Integration d31dd8a31b security: separate candidate owner approval signer 2026-07-25 23:07:22 +08:00
chatgpt-gitops-bot e45a775af2 fix(candidate): harden broker approval and Gitea trust 2026-07-25 19:44:09 +08:00
AIOS Candidate Writer ebf33ba34a fix(task-plane): emit ISO session expiry 2026-07-25 18:49:00 +08:00
AIOS Candidate Writer 1c1aa1700b feat(gitops): add scoped Gitea grant lifecycle 2026-07-25 18:44:10 +08:00
AIOS Candidate Writer fd8bea6a23 feat(gitops): add revocable Gitea credential projection 2026-07-25 18:39:03 +08:00
AIOS Candidate Writer bd9cdd8daf feat(gitops): add revocable Gitea credential projection 2026-07-25 18:39:03 +08:00
AIOS Candidate Writer c48ea38711 feat(gitops): add revocable Gitea credential projection 2026-07-25 18:39:03 +08:00
AIOS Candidate Writer c4f2ac8903 test(candidate): verify test_candidate_runtime.py 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 47af809a8b feat(gitops): add candidate candidate_writer.py 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 77eb2aeda9 feat(task-plane): add candidate candidate_session_broker.py 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer d57772ad3e feat(task-plane): add candidate candidate_common.py 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 838a8ed69f feat(gitops): add candidate bootstrap_candidate_runtime.py 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 51b98041d9 feat(openclaw): add task-plane task-plane.js 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 4398caaedf feat(openclaw): add task-plane sandbox.js 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 0c0be7abc3 feat(openclaw): add task-plane mock.js 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer f96b712b26 test(candidate): verify dispatch.test.js 2026-07-25 18:35:21 +08:00
AIOS Candidate Writer 8f55eff11d feat(openclaw): add task-plane session-broker-client.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 56d675166a feat(openclaw): add task-plane package.json 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer d3b0307159 feat(openclaw): add task-plane openclaw.plugin.json 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 0e832bbe9a feat(openclaw): add task-plane index.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 04ab2db654 feat(openclaw): add task-plane task-plane.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer bc18ae4446 feat(openclaw): add task-plane sandbox.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer ea74af9751 feat(openclaw): add task-plane mock.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 57c3ce6e96 feat(openclaw): add task-plane session-broker-client.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 4b57a6268c feat(openclaw): add task-plane index.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 88dcebee78 feat(openclaw): add task-plane dispatch-errors.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 12a973c2cb feat(openclaw): add task-plane dispatch-contract.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 0babec654b feat(openclaw): add task-plane dispatch-audit.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer d787e98554 feat(openclaw): add task-plane dispatch-errors.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer c0450946e4 feat(openclaw): add task-plane dispatch-contract.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer b4c30e16e0 feat(openclaw): add task-plane dispatch-audit.js 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer f04473f657 docs(openclaw): document candidate session provisioning 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer dedef75f8c feat(gitops): add candidate aios-session-broker-bootstrap-writer-candidate.service 2026-07-25 18:35:20 +08:00
AIOS Candidate Writer 18c43e42a4 feat(gitops): add candidate aios-openclaw-session-broker-candidate.service 2026-07-25 18:35:20 +08:00
ChatGPT Release Governance 95b3728abd fix(wechat): pseudonymize owner provenance principal 2026-07-22 13:46:53 +08:00
ChatGPT Release Governance e1659069b1 fix(wechat): bind dispatch provider server-side 2026-07-22 13:34:40 +08:00
ChatGPT Release Governance 4b35eb52cb fix(wechat): bind protected dispatch provider schema 2026-07-22 13:21:47 +08:00
ChatGPT Release Governance 05835675b6 fix(wechat): enforce singleton Owner binding 2026-07-22 12:54:32 +08:00
ChatGPT Release Governance a1a3a021ef fix(wechat): bind protected Owner identity explicitly 2026-07-22 12:37:29 +08:00
ChatGPT Release Governance 40e21d3c07 fix(wechat): await inbound metadata before reply 2026-07-22 11:55:20 +08:00
ChatGPT Release Governance 46932c46ca fix(wechat): retry dispatch without re-recording inbound 2026-07-22 11:43:40 +08:00
ChatGPT Release Governance 9e0a40600b fix(wechat): retry transient reply session conflicts 2026-07-22 11:25:12 +08:00
ChatGPT Release Governance d54da54033 fix(wechat): propagate sender identity and isolate conversations 2026-07-22 10:40:56 +08:00
ChatGPT Release Governance 33152a7f52 fix(wechat): separate ordinary conversation from Owner provenance 2026-07-22 10:19:09 +08:00
ChatGPT Release Governance 3dd622b5a5 v2r1c2_guard_plan_oid_supersession 2026-07-22 04:24:27 +08:00
ChatGPT Release Governance f73052cd5c v2r1c2_audit_manifest_supersession 2026-07-22 04:19:55 +08:00
ChatGPT Release Governance 1b69e89718 v2r1c2_add_audited_not_started_retry 2026-07-22 04:17:53 +08:00
ChatGPT Release Governance f3c00d7294 v2r1c2_harden_provenance_caches 2026-07-22 03:53:22 +08:00
ChatGPT Release Governance 80e3f65cd6 v2r1c2_takeover_gate 2026-07-22 03:46:34 +08:00
ChatGPT Release Governance 0cf769ef67 fix(v2r1c2): auto-issue Owner provenance for protected dispatch 2026-07-22 03:33:41 +08:00
ChatGPT Release Governance 5e53203a61 feat(v2r1c2): add legal ChatGPT takeover provider 2026-07-22 03:25:49 +08:00
ChatGPT Release Governance cf5afdf4c0 docs(task): add concise OpenClaw online execution handoff 2026-07-22 02:46:26 +08:00
ChatGPT Release Governance b6e714e3fc docs(v2r1c2): add unified portal and portable executor contract 2026-07-22 02:36:31 +08:00
ChatGPT Release Governance 0022adf958 test(release): add V2R1C2 offline and online acceptance gate 2026-07-22 02:33:37 +08:00
ChatGPT Release Governance 22888c3d53 test(orchestration): cover portable executor release gates 2026-07-22 02:29:33 +08:00
ChatGPT Release Governance c2c9eb8d81 fix(orchestration): retry failed owner notifications idempotently 2026-07-22 02:26:12 +08:00
ChatGPT Release Governance 6df9c92020 fix(web): repair execution center script and truth projection 2026-07-22 02:23:56 +08:00
ChatGPT Release Governance 6bb8c530a4 feat(web): render portable executor execution center 2026-07-22 02:18:31 +08:00
ChatGPT Release Governance 024143efa5 feat(deploy): add V2R1C2 candidate environment template 2026-07-22 02:10:29 +08:00
ChatGPT Release Governance 06c6374126 feat(deploy): add isolated V2R1C2 portal candidate service 2026-07-22 02:07:44 +08:00
ChatGPT Release Governance 82177403f6 feat(orchestration): add portable execution plan lifecycle CLI 2026-07-22 02:04:52 +08:00
ChatGPT Release Governance ef074a0c97 feat(release): add V2R1C2 portable multi-provider execution plan 2026-07-22 02:00:00 +08:00
ChatGPT Release Governance 00fd375e4c feat(web): wire portable executor plans into V2R1C2 candidate 2026-07-22 01:54:45 +08:00
ChatGPT Release Governance 5b0c3eb404 feat(web): expose provider-neutral execution plan APIs 2026-07-22 01:51:12 +08:00
ChatGPT Release Governance 3161470bd2 fix(orchestration): activate dependent stages only after upstream gates pass 2026-07-22 01:46:28 +08:00
ChatGPT Release Governance 1852701236 feat(web): add execution-center capability and navigation 2026-07-22 01:39:46 +08:00
ChatGPT Release Governance 21fc8c60ed refactor(orchestration): authorize task API from dynamic Provider trust 2026-07-22 01:33:56 +08:00
ChatGPT Release Governance 77395fa9a0 feat(orchestration): add official portable executor providers 2026-07-22 01:29:46 +08:00
ChatGPT Release Governance 490ed4f65a feat(orchestration): add provider-neutral executor worker runtime 2026-07-22 01:26:54 +08:00
ChatGPT Release Governance d6d2407a0d feat(orchestration): add portable multi-provider execution plans 2026-07-22 01:22:48 +08:00
ChatGPT Release Governance db3fcb89a8 feat(orchestration): persist and migrate executor provider catalogs 2026-07-22 01:18:40 +08:00
ChatGPT Release Governance 20b7a41104 refactor(orchestration): make executor protocol provider-based and portable 2026-07-22 01:10:32 +08:00
ChatGPT Release Governance e739e67052 feat(orchestration): unify OpenClaw Hermes and Codex executor protocol 2026-07-22 01:05:56 +08:00
ChatGPT Release Governance 1e6c7b9ad8 feat(orchestration): add protected external task lifecycle API 2026-07-22 00:59:25 +08:00
ChatGPT Release Governance 1afbb11d3b feat(orchestration): add OpenClaw-compatible external task CLI 2026-07-22 00:54:49 +08:00
ChatGPT Release Governance 850beec8d7 feat(orchestration): add external task discovery read model 2026-07-22 00:49:45 +08:00
ChatGPT Release Governance fb11c71ec0 feat(orchestration): require owner notification receipts in external task gate 2026-07-22 00:42:50 +08:00
ChatGPT Release Governance 8acdd104bb feat(orchestration): add durable external task truth registry 2026-07-22 00:38:48 +08:00
ChatGPT Release Governance 43d77e520f feat(web): add tenant and Agent resource center to candidate portal 2026-07-22 00:34:16 +08:00
ChatGPT Release Governance a3f731d01c fix(web): bridge portal-only roles to domain read policy 2026-07-22 00:13:45 +08:00
ChatGPT Release Governance 8dba5b8c2e fix(web): provide unified portal HTML package boundary 2026-07-22 00:06:22 +08:00
ChatGPT Release Governance 74158908e8 feat(web): add V2R1C2 portal release entrypoint and resource aggregation 2026-07-22 00:04:12 +08:00
ChatGPT Release Governance ab36e36d5d feat(web): expose unified portal package 2026-07-22 00:00:17 +08:00
ChatGPT Release Governance c1b14753a3 feat(web): add tenant and Agent resource capabilities 2026-07-21 23:54:08 +08:00
ChatGPT Release Governance c62c8ee9f6 feat(web): replace read-only console with unified role-aware portal 2026-07-21 23:49:52 +08:00
ChatGPT Release Governance 9626e66e5b feat(web): add unified portal aggregation and protected action API 2026-07-21 23:44:06 +08:00
ChatGPT Release Governance b68e3c85a3 feat(web): add server-side role capability matrix 2026-07-21 23:39:20 +08:00
ChatGPT Release Governance 74014514da feat(web): create unified role-aware portal branch from V2R1C1 2026-07-21 23:17:54 +08:00
Codex 4e0e6dcb90 chore(release): close V2R1C1 companion validation metadata 2026-07-21 19:05:28 +08:00
Codex 0778a31ec0 chore(release): record OpenClaw v2 companion validation 2026-07-21 18:29:45 +08:00
Codex 80c4cbde03 release:prepare-V2R1C1-validation-metadata 2026-07-21 17:04:24 +08:00
Codex fb739f6e8f docs(hermes): preserve formal acceptance blocker 2026-07-21 13:59:01 +08:00
Codex e6da39a3cd docs(hermes): record final evidence and worktree blocker 2026-07-21 13:55:08 +08:00
Codex 95d2e52cb5 fix(hermes): reserve cancellation for control plane 2026-07-21 13:41:34 +08:00
Codex 283dec58ab fix(hermes): enforce minimum heartbeat lease duration 2026-07-21 13:21:09 +08:00
Codex 7de1fe80ac docs(hermes): retain key-id and rollback contracts 2026-07-21 13:19:21 +08:00
Codex 89a502b39b test(hermes): preserve validated ten-case release gate 2026-07-21 13:19:21 +08:00
Codex 61bec280e2 deploy(hermes): preserve bounded rollback readiness 2026-07-21 13:19:21 +08:00
Codex 1791c91c5b docs(hermes): define lifecycle contract and validation state 2026-07-21 13:13:15 +08:00
Codex e8c0f0250b test(hermes): cover live lifecycle and recovery matrix 2026-07-21 13:09:06 +08:00
Codex 94718308fc fix(hermes): normalize trusted runtime headers and conflicts 2026-07-21 13:09:06 +08:00
Codex a4cbed0f5f feat(hermes): persist and protect full runtime lifecycle 2026-07-21 13:09:06 +08:00
98 changed files with 16807 additions and 302 deletions
@@ -0,0 +1,3 @@
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
@@ -0,0 +1,44 @@
{
"schema_version": "aios.evidence.manifest.v1",
"generated_at": "2026-07-21T05:50:00Z",
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
"evidence_hashes": {
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
},
"validation": {
"candidate_tests": {"passed": 49, "failed": 0},
"hermes_lifecycle": {"passed": 17, "failed": 0},
"gate_b": {"passed": 10, "failed": 0},
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
"openclaw_real_http": "PASS",
"restart_recovery": "PASS",
"rollback_forward": "PASS",
"state_preservation": "PASS",
"final_health_corrected_16R": "PASS"
},
"git_gate": {
"oid_match": true,
"candidate_clean": true,
"active_controller_clean": true,
"locked": 0,
"prunable": 0,
"worktree_total": 88,
"worktree_dirty": 10,
"global_worktree_clean": false
},
"resolved_blockers": [],
"active_blockers": [
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
],
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
"release_decision": "STOP_PREPRODUCTION",
"secrets_recorded": false
}
@@ -0,0 +1,16 @@
# AIOS V2R1 Hermes Lifecycle Result
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
- Release decision: `STOP_PREPRODUCTION`
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
@@ -0,0 +1,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 或部署预生产/生产。
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## V2R1C1 - 2.1.0-rc.1
- 固定发布基线为 `fb739f6e8ff9c031d319221ace68dce157694086`。
- 收口 Hermes 生命周期、Grant 隔离、Dispatch 恢复、运行证据和回滚/前滚验证。
- Portal Adapter 与 Spark Provisioning 仅保留在 Archive,不属于产品发布图。
- Future Incubator 保持隔离,不属于 V2R1C1 发布输入。
+1 -1
View File
@@ -1 +1 @@
V1R4C6
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",
]
+161 -5
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
import hashlib
import json
import sqlite3
import threading
from dataclasses import replace
from dataclasses import asdict, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable, Mapping
from .contract import (
@@ -41,6 +43,118 @@ class InMemoryHermesStore:
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
def persist_locked(self) -> None:
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
class SQLiteHermesStore(InMemoryHermesStore):
"""Durable snapshot for the existing Hermes queue state machine.
The adapter remains the sole owner of transitions and lease rules. This
store only makes its jobs, results, idempotency keys and worker heartbeats
survive a process restart.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
super().__init__()
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS hermes_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
payload TEXT NOT NULL
)
"""
)
self._load()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
record = asdict(value)
record["state"] = value.state.value
return record
def _load(self) -> None:
with self.lock, self._connect() as connection:
row = connection.execute(
"SELECT payload FROM hermes_state WHERE singleton = 1"
).fetchone()
if row is None:
return
payload = json.loads(str(row[0]))
self.jobs = {
str(item["job_id"]): HermesJob(**item)
for item in payload.get("jobs", [])
}
self.results = {
str(item["job_id"]): HermesResult(**item)
for item in payload.get("results", [])
}
self.idempotency = {
(str(item["tenant_id"]), str(item["idempotency_key"])): (
str(item["job_id"]),
str(item["digest"]),
)
for item in payload.get("idempotency", [])
}
self.worker_heartbeats = {
str(item["worker_id"]): (
str(item["heartbeat_at"]),
str(item["job_id"]) if item.get("job_id") is not None else None,
)
for item in payload.get("worker_heartbeats", [])
}
def persist_locked(self) -> None:
payload = {
"version": 1,
"jobs": [self._record(item) for item in self.jobs.values()],
"results": [self._record(item) for item in self.results.values()],
"idempotency": [
{
"tenant_id": tenant_id,
"idempotency_key": idempotency_key,
"job_id": job_id,
"digest": digest,
}
for (tenant_id, idempotency_key), (job_id, digest)
in self.idempotency.items()
],
"worker_heartbeats": [
{
"worker_id": worker_id,
"heartbeat_at": heartbeat_at,
"job_id": job_id,
}
for worker_id, (heartbeat_at, job_id)
in self.worker_heartbeats.items()
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
""",
(encoded,),
)
connection.commit()
class HermesAdapter:
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
@@ -118,6 +232,7 @@ class HermesAdapter:
return self.store.jobs[prior_id]
self.store.jobs[job.job_id] = job
self.store.idempotency[idem_ref] = (job.job_id, digest)
self.store.persist_locked()
return job
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
@@ -138,7 +253,8 @@ class HermesAdapter:
) -> list[HermesJob]:
state_filter = {HermesJobState(item) for item in states} if states else None
with self.store.lock:
self._requeue_expired_locked()
if self._requeue_expired_locked():
self.store.persist_locked()
jobs = [
job
for job in self.store.jobs.values()
@@ -157,6 +273,7 @@ class HermesAdapter:
profile_id: str,
worker_id: str,
lease_seconds: int | None = None,
job_id: str | None = None,
) -> HermesJob | None:
worker_id = str(worker_id or "").strip()
if not worker_id:
@@ -172,10 +289,12 @@ class HermesAdapter:
if job.tenant_id == tenant_id
and job.cell_id == cell_id
and job.profile_id == profile_id
and (job_id is None or job.job_id == job_id)
and job.state == HermesJobState.QUEUED
]
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
if not candidates:
self.store.persist_locked()
return None
current = sorted(
candidates,
@@ -190,6 +309,7 @@ class HermesAdapter:
),
)
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
self.store.persist_locked()
return claimed
def start(
@@ -201,9 +321,12 @@ class HermesAdapter:
expected_revision: int | None = None,
) -> HermesJob:
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
return self._transition_locked(current, HermesJobState.RUNNING)
updated = self._transition_locked(current, HermesJobState.RUNNING)
self.store.persist_locked()
return updated
def heartbeat(
self,
@@ -214,7 +337,10 @@ class HermesAdapter:
lease_seconds: int | None = None,
) -> HermesJob:
duration = int(lease_seconds or self.default_lease_seconds)
if duration < 5:
raise ValueError("lease_seconds must be >= 5")
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
raise HermesConflict("job does not accept heartbeat")
@@ -228,6 +354,7 @@ class HermesAdapter:
)
self.store.jobs[job_id] = updated
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
self.store.persist_locked()
return updated
def complete(
@@ -283,6 +410,8 @@ class HermesAdapter:
result = self.store.results.get(job_id)
if result is None:
raise HermesConflict("terminal result missing")
if result.state != HermesJobState.CANCELLED:
raise HermesConflict("job already has a different terminal result")
return result
updated = self._transition_locked(current, HermesJobState.CANCELLED)
result = HermesResult(
@@ -296,6 +425,7 @@ class HermesAdapter:
finished_at=utc_now(),
)
self.store.results[job_id] = result
self.store.persist_locked()
return result
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
@@ -309,9 +439,18 @@ class HermesAdapter:
raise HermesConflict("job has no terminal result")
return result
def requeue_expired(self) -> tuple[str, ...]:
"""Persistently release expired leases without changing policy state."""
with self.store.lock:
changed = self._requeue_expired_locked()
if changed:
self.store.persist_locked()
return tuple(sorted(changed))
def health(self) -> HermesHealth:
with self.store.lock:
self._requeue_expired_locked()
if self._requeue_expired_locked():
self.store.persist_locked()
active_jobs = sum(
1 for job in self.store.jobs.values()
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
@@ -354,7 +493,20 @@ class HermesAdapter:
expected_revision: int | None,
) -> HermesResult:
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state in TERMINAL_STATES:
existing = self.store.results.get(job_id)
if existing is None:
raise HermesConflict("terminal result missing")
if (
existing.state == state
and existing.worker_id == worker_id
and existing.output == output
and existing.error == error
):
return existing
raise HermesConflict("job already has a different terminal result")
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, state)
result = HermesResult(
@@ -371,6 +523,7 @@ class HermesAdapter:
)
self.store.results[job_id] = result
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
self.store.persist_locked()
return result
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
@@ -418,8 +571,9 @@ class HermesAdapter:
self.store.jobs[current.job_id] = updated
return updated
def _requeue_expired_locked(self) -> None:
def _requeue_expired_locked(self) -> set[str]:
now = datetime.now(timezone.utc)
changed: set[str] = set()
for current in list(self.store.jobs.values()):
expiry = self._parse(current.lease_expires_at)
if (
@@ -435,3 +589,5 @@ class HermesAdapter:
lease_expires_at=None,
updated_at=utc_now(),
)
changed.add(current.job_id)
return changed
+352 -6
View File
@@ -12,7 +12,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
from aios_openclaw import (
Ed25519CommandSigner,
HTTPRuntimeTransport,
@@ -23,7 +23,7 @@ from aios_openclaw.callback import AuditCallbackSink, EventPublisher
from .console import CONSOLE_HTML
from .control_plane import AuditEvent, RequestContext, Unauthorized
from .dispatch_store import SQLiteDispatchRepository
from .execution_tracking import ExecutionTracker
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .gateway import GatewayApplication, error_response
from .gitops_status import collect_gitops_status
from .orchestrator import ExecutionOrchestrator
@@ -143,13 +143,17 @@ 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-v2",
@@ -168,12 +172,16 @@ 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)
@@ -183,6 +191,108 @@ class CandidateApplication:
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,
@@ -269,6 +379,233 @@ class CandidateApplication:
)
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
lifecycle_match = __import__("re").fullmatch(
r"/api/v2/internal/runtime/hermes/lifecycle/"
r"(claim|start|heartbeat|complete|fail|cancel|status)",
path,
)
if method == "POST" and lifecycle_match:
if not self.hermes_lifecycle_api_enabled:
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
context = self.context(headers)
self._require_runtime_role(context)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
payload = body
action = lifecycle_match.group(1)
identity = {
name: self._required_identity(payload, name)
for name in (
"dispatch_id",
"job_id",
"tenant_id",
"task_id",
"trace_id",
"principal_id",
"provenance_id",
"run_id",
"cell_id",
"profile_id",
)
}
header_provenance, header_run = _provenance_context(headers, payload)
if header_provenance != identity["provenance_id"]:
raise ValueError("provenance_id context mismatch")
if header_run != identity["run_id"]:
raise ValueError("run_id context mismatch")
requeued_job_ids = self.hermes.requeue_expired()
dispatch, job = self.orchestrator.hermes_lifecycle_context(
context,
dispatch_id=identity["dispatch_id"],
job_id=identity["job_id"],
tenant_id=identity["tenant_id"],
task_id=identity["task_id"],
trace_id=identity["trace_id"],
principal_id=identity["principal_id"],
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
if job.cell_id != identity["cell_id"]:
raise ValueError("cell_id mismatch")
if job.profile_id != identity["profile_id"]:
raise ValueError("profile_id mismatch")
if action == "status":
if job.job_id in requeued_job_ids:
self._record_hermes_lifecycle(
context,
dispatch,
job,
action="lease_expired_requeue",
worker_id=None,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
task = self.orchestrator.control_plane.get_task(
context, dispatch.task_id
)
response: dict[str, Any] = {
"job": _json_value(job),
"dispatch": _json_value(dispatch),
"task": _json_value(task),
}
if job.state in {
HermesJobState.COMPLETED,
HermesJobState.FAILED,
HermesJobState.CANCELLED,
}:
response["result"] = _json_value(
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
)
return HTTPStatus.OK, response
expected_revision = payload.get("expected_revision")
expected = (
int(expected_revision) if expected_revision is not None else None
)
if action == "cancel":
self._require_control_plane_cancel(context)
if payload.get("worker_id") is not None:
raise Unauthorized("Hermes workers may observe but not initiate cancel")
if payload.get("cancellation_source") != "aios-control-plane":
raise ValueError(
"cancellation_source must be aios-control-plane"
)
result = self.hermes.cancel(
job.job_id,
tenant_id=job.tenant_id,
expected_revision=expected,
)
else:
worker_id = self._required_identity(payload, "worker_id")
if action == "claim":
claimed = self.hermes.claim(
tenant_id=job.tenant_id,
cell_id=job.cell_id,
profile_id=job.profile_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
job_id=job.job_id,
)
if claimed is None:
raise ValueError("Hermes job is not claimable")
bound = self.orchestrator.bind_hermes_worker(
context,
claimed,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
worker_id=worker_id,
)
self._record_hermes_lifecycle(
context,
bound,
claimed,
action="claim",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"job": _json_value(claimed),
"dispatch": _json_value(bound),
}
if action == "start":
started = self.hermes.start(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
expected_revision=expected,
)
self._record_hermes_lifecycle(
context,
dispatch,
started,
action="start",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(started)}
if action == "heartbeat":
heartbeat = self.hermes.heartbeat(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
)
self._record_hermes_lifecycle(
context,
dispatch,
heartbeat,
action="heartbeat",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
if action == "complete":
output = payload.get("output")
if not isinstance(output, Mapping):
raise ValueError("output must be an object")
result = self.hermes.complete(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
output=output,
expected_revision=expected,
)
elif action == "fail":
error = payload.get("error")
if not isinstance(error, Mapping):
raise ValueError("error must be an object")
result = self.hermes.fail(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
error=error,
expected_revision=expected,
)
else:
raise ValueError("unsupported Hermes lifecycle action")
task = self.orchestrator.reconcile_hermes(
context,
result,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
updated_dispatch = self.orchestrator.dispatch_repository.get(
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
)
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
self._record_hermes_lifecycle(
context,
updated_dispatch,
terminal_job,
action=action,
worker_id=result.worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"result": _json_value(result),
"task": _json_value(task),
"dispatch": _json_value(updated_dispatch),
}
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
context = self.context(headers)
self._require_runtime_role(context)
@@ -411,7 +748,7 @@ def build_application() -> CandidateApplication:
provenance_writer=provenance_writer,
require_provenance=require_provenance,
)
hermes = HermesAdapter()
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
@@ -446,7 +783,16 @@ def build_application() -> CandidateApplication:
require_provenance=require_provenance,
dispatch_repository=dispatch_repository,
)
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
return CandidateApplication(
gateway,
orchestrator,
tracker,
hermes,
openclaw,
hermes_lifecycle_api_enabled=_enabled(
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
),
)
def main() -> None:
+68 -54
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,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,
}
@@ -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
+18 -7
View File
@@ -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,
@@ -81,20 +83,27 @@ class GatewayApplication:
@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,
)
@@ -242,11 +251,13 @@ _ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
(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),
@@ -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}
@@ -353,6 +353,76 @@ class ExecutionOrchestrator:
if getattr(dispatch, field_name) != expected_value:
raise OrchestrationError(f"dispatch {field_name} mismatch")
def hermes_lifecycle_context(
self,
context: RequestContext,
*,
dispatch_id: str,
job_id: str,
tenant_id: str,
task_id: str,
trace_id: str,
principal_id: str,
provenance_id: str,
run_id: str,
) -> tuple[DispatchRecord, HermesJob]:
"""Resolve a runtime lifecycle request against its durable dispatch."""
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="hermes",
runtime_execution_id=job_id,
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if context.tenant_id != tenant_id:
raise OrchestrationError("runtime context tenant_id mismatch")
if context.principal_id != principal_id:
raise OrchestrationError("runtime context principal_id mismatch")
if context.trace_id != trace_id:
raise OrchestrationError("runtime context trace_id mismatch")
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self.hermes.get(job_id, tenant_id=tenant_id)
if job.task_id != task_id or job.trace_id != trace_id:
raise OrchestrationError("Hermes job identity mismatch")
return dispatch, job
def bind_hermes_worker(
self,
context: RequestContext,
job: HermesJob,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
worker_id: str,
) -> DispatchRecord:
dispatch, current = self.hermes_lifecycle_context(
context,
dispatch_id=dispatch_id,
job_id=job.job_id,
tenant_id=job.tenant_id,
task_id=job.task_id,
trace_id=job.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if current.assigned_worker_id != worker_id:
raise OrchestrationError("Hermes worker does not own claimed job")
return self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
expected_worker_id=worker_id,
)
def reconcile_openclaw(
self,
context: RequestContext,
@@ -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
]
+13
View File
@@ -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",
]
+512
View File
@@ -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()
+111
View File
@@ -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
+48
View File
@@ -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()
View File
+18 -6
View File
@@ -8,12 +8,28 @@ STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
TARGET_RELEASE="${1:-}"
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
fail() {
echo "ERROR: $*" >&2
exit 1
}
run_healthcheck() {
local release="$1"
local attempt
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
if AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$release/deploy/aios-v2/healthcheck/check.sh"; then
return 0
fi
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
done
return 1
}
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
@@ -53,9 +69,7 @@ mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
systemctl daemon-reload
systemctl start "$SERVICE"
if AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
if run_healthcheck "$TARGET_PATH"; then
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
exit 0
fi
@@ -78,9 +92,7 @@ systemctl daemon-reload
systemctl start "$SERVICE" || true
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
run_healthcheck "$PREVIOUS_PATH" || true
fi
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
@@ -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
@@ -0,0 +1,45 @@
[Unit]
Description=AIOS Candidate OpenClaw Session Broker
After=aios-session-broker-bootstrap-writer-candidate.service
Requires=aios-session-broker-bootstrap-writer-candidate.service
ConditionPathIsDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
ConditionPathExists=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/broker-policy.json
[Service]
Type=simple
User=aiman
Group=gitops
WorkingDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
Environment=PYTHONDONTWRITEBYTECODE=1
RuntimeDirectory=aios-openclaw-session-broker-candidate
RuntimeDirectoryMode=0700
RuntimeDirectoryPreserve=restart
LoadCredential=broker_control_token:/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/secrets/broker-control-token.source
ExecStart=/usr/bin/python3 /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725/services/aios-session-broker-candidate/candidate_session_broker.py --policy /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/broker-policy.json --database /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/broker-state.sqlite3 --projection-root /run/aios-openclaw-session-broker-candidate/sessions --writer-endpoint http://127.0.0.1:19932 --host 127.0.0.1 --port 19931
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictRealtime=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=any
IPAddressAllow=localhost
UMask=0077
CapabilityBoundingSet=
AmbientCapabilities=
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725
ReadWritePaths=/run/aios-openclaw-session-broker-candidate
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,46 @@
[Unit]
Description=AIOS task-scoped Candidate Bootstrap Writer
After=network.target
ConditionPathIsDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
ConditionPathExists=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/writer-policy.json
[Service]
Type=simple
User=aiman
Group=gitops
WorkingDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
Environment=PYTHONDONTWRITEBYTECODE=1
RuntimeDirectory=aios-session-broker-bootstrap-writer-candidate
RuntimeDirectoryMode=0700
RuntimeDirectoryPreserve=restart
LoadCredential=writer_token:/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/secrets/writer-token.source
LoadCredential=gitea_git_http:/etc/aios-gitops/credentials/gitea-git-http
ExecStart=/usr/bin/python3 /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725/services/aios-session-broker-candidate/candidate_writer.py --policy /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/writer-policy.json --worktree /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725 --git-dir /run/aios-session-broker-bootstrap-writer-candidate/candidate.git --state /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/writer-state.json --audit /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/writer-audit.jsonl --host 127.0.0.1 --port 19932
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictRealtime=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=any
IPAddressAllow=localhost
UMask=0077
CapabilityBoundingSet=
AmbientCapabilities=
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
ReadWritePaths=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725
ReadWritePaths=/run/aios-session-broker-bootstrap-writer-candidate
[Install]
WantedBy=multi-user.target
+11 -1
View File
@@ -221,6 +221,14 @@ OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完
- 至少校验 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 持久化与恢复
@@ -232,6 +240,8 @@ Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
- 终态 Dispatch 不得迁移到不同终态。
- Hermes Job、Result、Lease、幂等键和 Worker heartbeat 必须与 Dispatch 一样在
服务重启后恢复;不得以只存在于进程内的 Map 作为在线验收依据。
- 有 SQLite Repository 重启恢复测试。
### UR-020 OpenClaw Ed25519 命令合同
@@ -329,4 +339,4 @@ OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 H
7. 租户用户在 Web Console 查看完整链路。
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
+27
View File
@@ -4,6 +4,10 @@
- 状态:加固候选契约基线
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
核心模型名称与实现保持逐字同步:`RequestContext` 承载可信租户、主体、角色、trace、
session 与 policy version;`TaskState` 是 AIOS 权威 Task 生命周期枚举,Runtime Worker
不得直接写入或复制该状态机。
## 1. 通用可信上下文契约
所有控制与执行请求必须携带以下上下文:
@@ -345,6 +349,8 @@ signature
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
- Fingerprint 使用恒定时间比较。
- `key_id` 只引用密钥,不包含密钥材料。
- Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
不得在已有批准 Key ID 时回落为默认值。
### 11.2 OpenClawResult
@@ -374,6 +380,17 @@ attempts
## 12. Hermes Contract
### 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
@@ -400,6 +417,10 @@ updated_at
- `tenant_id + cell_id + profile_id` 形成执行分区。
- claim 只能领取同分区 Job。
- 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 冲突不得覆盖。
### 12.2 HermesResult
@@ -422,6 +443,8 @@ finished_at
- 终态 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`。
@@ -566,6 +589,10 @@ POST /api/v2/tasks/{id}/fail -> 403
- 默认只回滚代码。
- 状态恢复需独立 Rollback Grant 和显式批准。
- 回滚后必须执行 Healthcheck 与恢复验证。
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
验收测试必须阻止非执行模式进入候选包。
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
## 18. 契约变更规则
+5 -5
View File
@@ -20,23 +20,23 @@
|---|---|---|---|---|---|
| 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;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Reconcile | `test_candidate_flow.py` | `/api/v2/internal/runtime/hermes/reconcile`;Runtime callback 审计检查 | 已实现;测试已提交;待验收 |
| 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` | 幂等冲突、Lease、终态冲突 Acceptance | 已实现;测试代码覆盖部分场景;待验收 |
| 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;模块契约第 10、16 节 | `persistence.py`、`dispatch_store.py`、`provenance.py`、`provenance_grants.py` | `test_candidate_flow.py` 中 Control Plane 与 Dispatch recovery | 重启、备份、恢复、锁等待、容量测试 | 候选实现完成;恢复测试已提交;生产 HA/容量待验收 |
| 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;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Reconcile | `test_candidate_flow.py` | OpenClaw/Hermes 伪造 tenant/task/trace/principal/provenance/run 负向验收 | 已实现;恢复正向测试已提交;完整负向验收待执行 |
| UR-019 Dispatch 持久化与恢复 | ADR-009;模块契约第 10 节 | `dispatch_store.py`、`orchestrator.py`、`app.py` | `test_candidate_flow.py::test_dispatch_and_control_plane_recover_before_hermes_reconcile` | 进程重启后读取 Dispatch 并完成 Reconcile | 已实现;测试已提交;待执行 |
| 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 端到端验收待执行 |
@@ -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`、不替换生产。
+78 -214
View File
@@ -1,240 +1,104 @@
# AIOS V2R1 加固与实际验收刷新报告
# AIOS V2R1 Hermes 生命周期最终验证报告
- 报告版本:`V2R1-execution-refresh-20260721.1`
- 报告版本:`V2R1-hermes-lifecycle-20260721.3`
- 日期:`2026-07-21`
- 分支:`patch/chatgpt/AIOS-V2R1-HARDENING-20260721`
- 总体状态:`PARTIAL / BLOCKED_EVIDENCE_REQUIRED`
- 分支:`patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
- 固定验证 OID:`95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
- 总体状态:`PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
- 发布决策:`STOP_PREPRODUCTION`
- Gate A:`PASS`(执行方报告 `0 errors / 0 warnings`)
- Gate B:`PASS`(执行方报告 `10/10`)
- 离线 Acceptance:`PASS_WITH_EXPECTED_WARNING`(`8 passed / 0 failed / 1 expected warning`)
- 生产发布:`NOT_APPROVED`
> 本报告刷新并取代此前“Run Provenance 尚未实现、Gate A 不允许执行”的旧快照。当前五项原始高风险 Blocker 均已解除。唯一剩余发布阻塞是 Live AIOS/Hermes 缺少可调用的完整在线 Worker 生命周期端点与对应证据。
## 1. 执行结论
## 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、重启恢复、双向回滚和最终健康检查均通过。
本报告依据执行方提供的实际执行结论、OID、同步状态、验收统计和日志路径刷新。当前 GitOps 工具可以提交和读取仓库内容,但不能直接读取所列主机绝对路径下的日志,因此:
但 Owner 指令规定只有全部 Acceptance 条件同时满足时才允许正式解除该 Blocker。由于“所有 Worktree clean”失败,本报告不得把它标记为正式 `RESOLVED`;准确状态是 `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`。
- 本报告将这些结果标记为“执行方已实际验证并提供证据路径”。
- 本会话未独立重放或读取日志内容。
- Merge、Tag、生产部署和风险接受仍需 AIOS Owner 独立批准。
- 任何与原始日志不一致的情况,应以不可变日志、退出码和签名 Evidence 为准。
最终发布门禁仍失败:权威 Bare Repo 登记的 88 个 worktree 中有 10 个脏 worktree。候选 worktree 和活动 Controller worktree 均 clean,且无 locked/prunable;但 Owner 的验收条件要求所有 worktree clean,因此不得准备 RC、不得发起 Tag Approval Request、不得进入预生产评审。
主要证据路径:
## 2. 关键实现
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/online-runtime-openclaw-final4.log`
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/restart-recovery.log`
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/rollback-drill-final3.log`
- `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` 控制,默认关闭。
## 2. 权威版本与仓库状态
## 3. 固定 OID 验证
| 项目 | 刷新值 | 状态 |
|---|---|---|
| 权威 Bare Repo | `/data/aios-gitops/repo.git` | 已报告可用 |
| Candidate OID | `ba7669c3b49f98cf3500af6ea966ba0db472ff3b` | 已同步 |
| OpenClaw Task/Gitea OID | `0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31` | `local_oid == gitea_oid` |
| Codex Task/Gitea OID | `6b0db21a852928106600914075ca50114cb07def` | `local_oid == gitea_oid` |
| 活动隔离 Release OID | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 当前验收 Release |
| Controller 修复 OID | `6188da26453505dacde9d6d298191437164c8d27` | detached、clean、fully synced |
| OpenClaw Runtime 插件 | `2.1.0-rc.3` | 已用于在线验收 |
| OpenClaw 包含最新 Candidate | 是 | 通过 |
| OpenClaw/Codex Worktree | clean | 通过 |
| Worktree 锁 | `0` | 通过 |
| Prunable Worktree | `0` | 通过 |
| `main` 修改 | 无 | 通过 |
| Tag 创建 | 无 | 通过 |
| Force Push | 无 | 通过 |
| 生产流量切换 | 无 | 通过 |
## 3. 原始五项高风险 Blocker
以下五项均已解除,当前未发现回退:
| Blocker | 刷新状态 | 证据摘要 |
|---|---|---|
| `BLK-OPENCLAW-PLAINTEXT-SECRETS` | `RESOLVED` | `plaintext/unresolved/shadowed/legacy = 0`;Gateway Token 已轮换;未记录 Secret 值 |
| `BLK-AIOS-V2-SIGNING-MATERIAL-MISSING` | `RESOLVED` | Ed25519 Key ID `openclaw-v2r1-candidate-ed25519`;`aios:aios 0600`;sign/verify、篡改拒绝通过 |
| `BLK-CANDIDATE-NOT-MERGED` | `RESOLVED` | OpenClaw 包含最新 Candidate;三分支本地与 Gitea OID 一致 |
| `BLK-OPENCLAW-RUN-PROVENANCE-MISSING` | `RESOLVED` | 已实现 Workflow Provenance、持久 Store、最小权限 Child Grant、Scoped Writer、Dispatch/Result 严格绑定及相关测试 |
| `BLK-CONTROLLER-LEGACY-GITEA-CHECK` | `RESOLVED` | 活跃代码清除旧 `aios-v23` 硬编码;Controller 12/12;Token 降权为 `read:repository` 并通过 systemd `LoadCredential` 注入 |
### 3.1 Run Provenance 刷新说明
旧状态认为插件只有进程内 `runId/sender` Map,缺少 Owner-signed schema、持久 Store 和专用 Writer。当前加固实现已经提供:
- 无操作权限的父 Workflow Provenance;
- Ed25519 签名与公钥验证;
- SQLite WAL Provenance/Grant/Usage/Event Store;
- Runtime、Evidence、Git、Deployment、Rollback、Audit、Code Change 独立 Child Grant;
- GitOperation 级别限制;
- 服务级 Scoped Writer 与 CLI 单 Grant 授权;
- 撤销、过期、重放和上下文绑定;
- 持久 Dispatch 与 Runtime Result 严格 Reconcile。
因此,`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 不再成立。
## 4. 安全验证结果
### 4.1 Secret 与凭据
| 检查 | 结果 |
| 项目 | 结果 |
|---|---|
| 原明文 Secret 发现 | `3` |
| 当前明文 Secret | `0` |
| unresolved | `0` |
| shadowed | `0` |
| legacy | `0` |
| Gateway Token 轮换 | 通过 |
| OneAPI 实际 Agent 调用 | 通过 |
| Controller Token 权限 | `read:repository` |
| Controller Token 注入 | systemd `LoadCredential` |
| Secret 值写入记录 | 否 |
| 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 |
### 4.2 Signing Material
原 `16.log` 由非服务用户执行健康脚本,打印 state-dir 权限失败后被外层脚本错误覆盖为退出码 0,因此不作为通过证据。纠错后的 `16R.log` 使用受控 `sudo env`,子 shell 保留失败码,所有健康项实际 PASS 且 `EXIT_CODE=0`。原文件保留用于审计。
| 检查 | 结果 |
|---|---|
| 文件存在 | 是 |
| Key ID | `openclaw-v2r1-candidate-ed25519` |
| 所有者/权限 | `aios:aios 0600` |
| 合同 | 真实 Ed25519,`aios.openclaw.command.v2` |
| sign/verify | 通过 |
| 篡改拒绝 | 通过 |
| 过期拒绝 | 通过 |
| 跨租户拒绝 | 通过 |
回滚日志中的短暂 connection-refused 发生于 systemd 重启窗口内,受 `AIOS_V2_ROLLBACK_HEALTH_RETRIES=30` 与 1 秒间隔限制;前后两次均在界限内达到健康、输出 `ROLLBACK_COMPLETE`,并完成状态文件精确哈希对账。
## 5. Gate 与 Acceptance 刷新
## 4. Evidence
| Gate/验收项 | 实际结果 | 刷新状态 |
- 根目录:`/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 与运行态门禁
| 门禁 | 当前值 | 结果 |
|---|---|---|
| Gate A | `0 errors / 0 warnings` | `PASS` |
| Gate B | `10/10` | `PASS` |
| 离线 Acceptance | `8 passed / 0 failed / 1 expected warning` | `PASS_WITH_EXPECTED_WARNING` |
| OpenClaw 在线 Health | 通过 | `PASS` |
| OpenClaw Execute | 通过 | `PASS` |
| OpenClaw Status | 通过 | `PASS` |
| OpenClaw Cancel | 通过 | `PASS` |
| OpenClaw Timeout | 通过 | `PASS` |
| OpenClaw 幂等 | 通过 | `PASS` |
| OpenClaw 篡改拒绝 | 通过 | `PASS` |
| OpenClaw 过期拒绝 | 通过 | `PASS` |
| OpenClaw 跨租户拒绝 | 通过 | `PASS` |
| AIOS → OpenClaw 真实 HTTP | `COMPLETED` | `PASS` |
| AIOS/Hermes 基础 enqueue/reconcile | 通过 | `PASS` |
| OpenClaw 跨重启幂等 | 通过 | `PASS` |
| AIOS Tenant/Agent/Task/Audit/Idempotency/Evidence 恢复 | 通过 | `PASS` |
| SQLite `PRAGMA quick_check` | `ok` | `PASS` |
| 受控双向回滚 | Previous `2a2b702…` ↔ Current `b158041…` | `PASS` |
| 回滚业务状态保护 | 未覆盖业务状态 | `PASS` |
| Live Hermes 完整生命周期 | 在线端点与证据不完整 | `BLOCKED_EVIDENCE_REQUIRED` |
| 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 |
## 6. Controller Health
10 个脏 worktree 均不属于本任务允许清理的范围:
| 检查 | 结果 |
|---|---|
| 活跃修复 OID | `6188da26453505dacde9d6d298191437164c8d27` |
| 旧 `aios-v23` 硬编码 | 活跃代码中已清除 |
| Controller 测试 | `12/12` |
| 部署 Worktree | detached、clean |
| Gitea | `true` |
| local_sync | `synced` |
| gitea_sync | `synced` |
| oid_match | `true` |
| branch_sync | `true` |
| sync_status | `fully_synced` |
| 首次部署失败回滚 | 已实际触发并验证 |
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)
## 7. 当前唯一发布阻塞
## 6. Reviewer
### `BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
独立 Spark Reviewer 首先发现 cancel 权限边界不够明确;该问题已在 `95d2e52` 修复并新增三条 HTTP 断言。纠错后 Reviewer 确认代码安全边界、16 步证据与有界回滚均 PASS。对发布门禁的最终布尔复核为:`DIRTY_WORKTREE_COUNT=10` 且 `GLOBAL_WORKTREE_CLEAN=false`,所以全局门禁 FAIL,不能进入预生产评审。
Live AIOS 当前只暴露 Hermes `enqueue/reconcile`,没有可调用或可观测的完整在线 Worker 生命周期端点,因此以下场景尚无完整在线证据:
## 7. Blocker 与发布决策
- `claim`
- `start`
- `heartbeat`
- `fail`
- `cancel`
- `lease expiry`
- `worker crash recovery`
- `Cell/Profile rejection`
- `invalid-result rejection`
- 功能与证据子条件通过、正式关闭受阻:`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
- 直接阻断条件:`BLK-GLOBAL-WORKTREE-CLEANLINESS`
- 状态:`BLOCKED_EVIDENCE_REQUIRED`
- 决策:`STOP_PREPRODUCTION`
这不是 Run Provenance 缺失,也不是基础 Hermes Adapter 失效;它是在线验收可观测面和完整生命周期证据不足。
本轮未修改 `main`、未创建 Tag、未 Force Push、未切换生产流量、未部署新 RC、未输出 Secret。
### 7.1 解除条件
必须在隔离预发布环境中实际完成并保存:
1. Worker claim/start/heartbeat 正向链路;
2. Worker fail/cancel 终态链路;
3. Lease 到期重新排队或重新领取;
4. Worker 进程崩溃后的恢复;
5. 错误 Cell/Profile 的拒绝;
6. 非 Lease Owner 或上下文不匹配 Result 的拒绝;
7. Dispatch、Task、Audit、Evidence、Grant Receipt 的一致性;
8. 服务重启后继续 Reconcile;
9. 完整命令、时间、退出码、日志路径和 Artifact Hash;
10. Owner/Reviewer 对证据的独立复核。
### 7.2 不接受的替代证据
以下不能解除本 Blocker:
- 仅有单元测试文件;
- 仅有离线 Hermes enqueue/reconcile;
- 仅证明 OpenClaw 在线链路;
- 仅修改状态文档;
- 仅提供无退出码或无日志路径的口头结论;
- 通过手工修改 Task 状态伪造 Worker 生命周期。
## 8. 发布决策
### 8.1 当前决策
```text
Overall: PARTIAL / BLOCKED_EVIDENCE_REQUIRED
Gate A: PASS
Gate B: PASS
Offline: PASS_WITH_EXPECTED_WARNING
OpenClaw Live: PASS
Hermes Basic: PASS
Hermes Full: BLOCKED
Preproduction: STOP
Production: NOT APPROVED
```
### 8.2 允许事项
- 继续在当前非 `main` 加固分支修复与补证据;
- 在隔离 Release/预发布环境增加 Hermes 生命周期端点或受控验收驱动;
- 运行在线负向测试、重启恢复和回滚演练;
- 更新 `.agent` Evidence、Acceptance 日志与本报告;
- 提交 Reviewer 可复核的 Evidence Manifest。
### 8.3 禁止事项
- 不得修改 `main`;
- 不得创建生产 Tag;
- 不得 Force Push;
- 不得切换生产流量;
- 不得因 Gate A/B 和 OpenClaw 已通过而忽略 Hermes 完整在线证据;
- 不得把当前状态描述为“全部完成”或“可放行预生产”。
## 9. 后续最小执行计划
1. 为 Live AIOS/Hermes 提供受保护的内部 Worker 生命周期验收面,或使用等价受信测试驱动直接调用真实在线 Queue/Lease/Worker 实现。
2. 执行完整 Hermes 正向、负向、Lease、Crash、Restart 场景。
3. 保存日志、退出码、时间戳、OID、配置摘要和 Artifact Hash。
4. 将 Evidence 写入租户隔离 `.agent` 路径并提交非 `main` 分支。
5. 由独立 Reviewer 复核 Result/Dispatch/Task/Audit/Grant Receipt 一致性。
6. 仅在 Blocker 解除后将总体状态刷新为 `READY_FOR_PREPRODUCTION_REVIEW`;最终放行仍需 Owner 批准。
## 10. 最终结论
五项原始高风险 Blocker 已全部解除,Gate A、Gate B、离线 Acceptance、OpenClaw 在线链路、基础 Hermes、重启恢复、SQLite 完整性和双向回滚均已有执行方报告的实际通过结果。
但 Live Hermes 完整 Worker 生命周期仍缺少可调用端点和完整在线证据。因此本次刷新结论为:
> **AIOS V2R1 已从“核心安全 Blocker 未解除”提升为“核心加固与主要验收通过,但 Hermes 在线生命周期证据未闭环”。继续停止预生产放行,直到 `BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 被实际证据解除。**
下一步唯一允许动作:由各 worktree Owner 审查并提交、归档或可信清理上述 10 个脏 worktree,然后重新执行全局 Git 门禁。只有全局 dirty 数量降为 0 后,才允许准备 RC 与 Tag Approval Request。
@@ -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
View File
@@ -16,6 +16,7 @@
| 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. 编号规则
+25
View File
@@ -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-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,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,64 @@
# 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` 等未授权路径的拒绝测试。
## Candidate task-plane transport
`protectedDispatchTransport=task-plane` 仅用于隔离 Candidate 验证。它要求:
- TaskPlan 与服务器配置的 `task_id`、repository、patch branch、完整 base OID、允许路径和 capability 完全一致;
- Owner provenance 已通过现有签名校验,Policy approval 与 TaskPlan 摘要、Owner principal、有效期绑定;
- Broker 端点只能是 loopback HTTP,Bearer 凭据只能从绝对路径的受控凭据投影读取;
- Broker 返回 `simulation=false`、真实 provisioning 元数据和 `secret-ref://` 引用,禁止返回 Token 或 Authorization 内容;
- Broker 不可达时直接失败,不回退到 `mock` 或 `sandbox`;
- 配置 `protectedDispatchAuditPath` 时仅写入脱敏 JSONL 审计记录。
该 Candidate 不修改生产插件或 Gateway;OpenClaw/Codex 原生子会话未完成前必须分别报告为未验证。
@@ -0,0 +1,45 @@
import fs from "node:fs";
import path from "node:path";
function redacted(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const scrubbed = {};
for (const [key, item] of Object.entries(value)) {
if (key === "token" || key === "authorization" || key === "secret" || key === "credential") {
continue;
}
if (item && typeof item === "object") {
scrubbed[key] = redacted(item);
} else if (typeof item === "string" && ["x-aios-token", "x-api-token", "authorization"].includes(key.toLowerCase())) {
scrubbed[key] = "[redacted]";
} else {
scrubbed[key] = item;
}
}
return scrubbed;
}
function appendRecord(auditPath, record) {
const parent = path.dirname(auditPath);
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
const fd = fs.openSync(auditPath, "a", 0o640);
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.chmodSync(auditPath, 0o640);
}
export function appendDispatchAudit(auditPath, record) {
if (!auditPath) return;
const absolute = path.isAbsolute(auditPath);
if (!absolute) throw new Error("invalid_audit_path");
const payload = {
schema_version: "aios.protected-dispatch-audit/v1",
recorded_at: Date.now(),
...(redacted(record) || {}),
};
appendRecord(auditPath, payload);
}
@@ -0,0 +1,300 @@
import crypto from "node:crypto";
import { canonicalize } from "./provenance.js";
const FORBIDDEN_BRANCHES = new Set(["main", "master", "trunk"]);
const OID_RE = /^[0-9a-f]{40}$/;
const BRANCH_RE = /^(?!\/)(?!.*(?:\.\.|@\{|\/\/|[~^:?*[\]\\\s]))(?!.*\/$)[A-Za-z0-9._/-]{1,240}$/;
const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RECIPIENT_BINDING_RE = /^principal:sha256:[0-9a-f]{64}$/;
const SECRET_KEYS = /^(?:token|access_token|authorization|authorization_header|credential|credential_value|secret|secret_value)$/i;
const DEFAULT_CAPABILITIES = Object.freeze([
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
]);
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function string(value) {
return typeof value === "string" ? value.trim() : "";
}
function exactStringArray(value, field, { max = 64 } = {}) {
if (!Array.isArray(value) || value.length === 0 || value.length > max) {
return { ok: false, code: `${field}_invalid` };
}
const values = value.map(string);
if (values.some((item) => !item) || new Set(values).size !== values.length) {
return { ok: false, code: `${field}_invalid` };
}
return { ok: true, values };
}
function sameSet(left, right) {
return left.length === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
}
function parseIso(value, field) {
const timestamp = Date.parse(string(value));
return Number.isFinite(timestamp) ? { ok: true, timestamp } : { ok: false, code: `${field}_invalid` };
}
function containsSecretField(value) {
if (Array.isArray(value)) return value.some(containsSecretField);
if (!object(value)) return false;
return Object.entries(value).some(([key, item]) => SECRET_KEYS.test(key) || containsSecretField(item));
}
export function deriveRequiredCapabilities(action) {
if (!new Set(["candidate.session.provision", "candidate.repository.change"]).has(string(action))) {
return { ok: false, code: "task_plane_action_not_allowed" };
}
return { ok: true, capabilities: [...DEFAULT_CAPABILITIES] };
}
export function taskPlanDigest(taskPlan) {
return crypto.createHash("sha256").update(canonicalize(taskPlan)).digest("hex");
}
export function validateTaskPlaneRequest({ request, permit, config = {}, now = Date.now() }) {
const payload = object(request?.payload);
const plan = object(payload?.task_plan);
const approval = object(payload?.approval_record);
if (!plan) return { ok: false, code: "protected_dispatch_task_plan_missing" };
if (!approval) return { ok: false, code: "task_plan_approval_missing" };
const expectedPlanKeys = [
"task_id", "parent_session_id", "requested_role", "required_capabilities",
"repository", "branch", "approved_base_oid", "allowed_paths", "credential_policy",
"result_delivery",
].sort();
const actualPlanKeys = Object.keys(plan).sort();
if (actualPlanKeys.length !== expectedPlanKeys.length || expectedPlanKeys.some((key) => !actualPlanKeys.includes(key))) {
return { ok: false, code: "task_plan_schema_mismatch" };
}
const taskId = string(plan.task_id);
if (!TASK_ID_RE.test(taskId) || taskId !== string(request.task_id) || taskId !== string(permit?.record?.payload?.task_id)) {
return { ok: false, code: "task_plan_task_mismatch" };
}
const parentSessionId = string(plan.parent_session_id);
if (!parentSessionId) return { ok: false, code: "parent_session_id_required" };
if (string(plan.requested_role) !== "dedicated_platform_writer") {
return { ok: false, code: "requested_role_rejected" };
}
const repository = string(plan.repository);
const branch = string(plan.branch);
const baseOid = string(plan.approved_base_oid).toLowerCase();
if (!REPOSITORY_RE.test(repository)) return { ok: false, code: "repository_invalid" };
if (!BRANCH_RE.test(branch) || FORBIDDEN_BRANCHES.has(branch.toLowerCase())) {
return { ok: false, code: "task_plan_branch_rejected" };
}
if (!OID_RE.test(baseOid)) return { ok: false, code: "task_plan_base_invalid" };
for (const [actual, expected, code] of [
[taskId, string(config.taskPlaneTaskId), "task_plan_task_not_configured"],
[repository, string(config.taskPlaneRepository), "task_plan_repository_mismatch"],
[branch, string(config.taskPlaneBranch), "task_plan_branch_mismatch"],
[baseOid, string(config.taskPlaneApprovedBaseOid).toLowerCase(), "task_plan_base_mismatch"],
]) {
if (!expected || actual !== expected) return { ok: false, code };
}
const pathCheck = exactStringArray(plan.allowed_paths, "task_plan_allowed_paths");
if (!pathCheck.ok) return pathCheck;
const configuredPaths = Array.isArray(config.taskPlaneAllowedPaths) ? config.taskPlaneAllowedPaths.map(string) : [];
if (!sameSet(pathCheck.values, configuredPaths)) return { ok: false, code: "task_plan_path_rejected" };
const derived = deriveRequiredCapabilities(request.action);
if (!derived.ok) return derived;
const capabilityCheck = exactStringArray(plan.required_capabilities, "task_plan_required_capabilities");
if (!capabilityCheck.ok) return capabilityCheck;
const configuredCapabilities = Array.isArray(config.taskPlaneAllowedCapabilities)
? config.taskPlaneAllowedCapabilities.map(string)
: [...DEFAULT_CAPABILITIES];
if (!sameSet(derived.capabilities, configuredCapabilities) || !sameSet(capabilityCheck.values, derived.capabilities)) {
return { ok: false, code: "task_plan_capability_mismatch" };
}
const credentialPolicy = object(plan.credential_policy);
if (!credentialPolicy || Object.keys(credentialPolicy).sort().join(",") !== "max_uses,ttl_seconds") {
return { ok: false, code: "credential_policy_invalid" };
}
const ttlSeconds = Number(credentialPolicy.ttl_seconds);
const maxUses = Number(credentialPolicy.max_uses);
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 1800 ||
!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 100) {
return { ok: false, code: "credential_policy_invalid" };
}
const resultDelivery = object(plan.result_delivery);
const expectedResultDeliveryKeys = [
"channel", "recipient_binding", "content_type", "encoding", "image_only",
].sort();
if (!resultDelivery ||
Object.keys(resultDelivery).sort().join(",") !== expectedResultDeliveryKeys.join(",") ||
string(resultDelivery.channel) !== "supervisor" ||
string(resultDelivery.content_type) !== "text/plain" ||
string(resultDelivery.encoding).toLowerCase() !== "utf-8" ||
resultDelivery.image_only !== false ||
!RECIPIENT_BINDING_RE.test(string(resultDelivery.recipient_binding)) ||
string(resultDelivery.recipient_binding) !== string(config.taskPlaneResultRecipientBinding)) {
return { ok: false, code: "task_plan_result_delivery_rejected" };
}
const expectedApprovalKeys = [
"approval_id", "task_id", "decision", "policy_id", "owner_principal_id",
"task_plan_sha256", "approved_capabilities", "not_before", "expires_at",
].sort();
const actualApprovalKeys = Object.keys(approval).sort();
if (actualApprovalKeys.length !== expectedApprovalKeys.length ||
expectedApprovalKeys.some((key) => !actualApprovalKeys.includes(key))) {
return { ok: false, code: "approval_record_schema_mismatch" };
}
if (!string(approval.approval_id) || string(approval.task_id) !== taskId ||
string(approval.decision) !== "approved" ||
string(approval.policy_id) !== string(config.taskPlanePolicyId) ||
string(approval.owner_principal_id) !== string(permit.record.payload.principal_id) ||
string(approval.task_plan_sha256) !== taskPlanDigest(plan)) {
return { ok: false, code: "approval_record_mismatch" };
}
const approvalCapabilities = exactStringArray(approval.approved_capabilities, "approval_capabilities");
if (!approvalCapabilities.ok || !sameSet(approvalCapabilities.values, derived.capabilities)) {
return { ok: false, code: "task_plan_capability_mismatch" };
}
const notBefore = parseIso(approval.not_before, "approval_not_before");
const expiresAt = parseIso(approval.expires_at, "approval_expires_at");
if (!notBefore.ok) return notBefore;
if (!expiresAt.ok) return expiresAt;
if (notBefore.timestamp > now || expiresAt.timestamp <= now || expiresAt.timestamp - notBefore.timestamp > 1_800_000) {
return { ok: false, code: "task_plan_approval_expired" };
}
return {
ok: true,
plan: {
task_id: taskId,
parent_session_id: parentSessionId,
requested_role: "dedicated_platform_writer",
required_capabilities: derived.capabilities,
repository,
branch,
approved_base_oid: baseOid,
allowed_paths: pathCheck.values,
credential_policy: { ttl_seconds: ttlSeconds, max_uses: maxUses },
result_delivery: {
channel: "supervisor",
recipient_binding: string(resultDelivery.recipient_binding),
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
},
},
approval: {
approval_plan_id: string(approval.approval_id),
policy_id: string(approval.policy_id),
owner_provenance_id: string(permit.record.payload.provenance_id),
task_plan_sha256: string(approval.task_plan_sha256),
not_before: string(approval.not_before),
expires_at: string(approval.expires_at),
},
};
}
export function buildApprovalPayload(
validated,
{ idempotencyKey, riskClass, signingKeyId },
) {
const approvalPlanId = validated.approval.approval_plan_id;
const nonceSeed = canonicalize({
approval_plan_id: approvalPlanId,
task_id: validated.plan.task_id,
idempotency_key: string(idempotencyKey),
});
return {
approval_plan_id: approvalPlanId,
task_id: validated.plan.task_id,
task_plan_sha256: validated.approval.task_plan_sha256,
repository: validated.plan.repository,
branch: validated.plan.branch,
approved_base_oid: validated.plan.approved_base_oid,
allowed_paths: [...validated.plan.allowed_paths],
approved_capabilities: [...validated.plan.required_capabilities],
risk_class: string(riskClass),
not_before: validated.approval.not_before,
expires_at: validated.approval.expires_at,
nonce: `approval-nonce-v1:${crypto.createHash("sha256").update(nonceSeed).digest("hex")}`,
signing_key_id: string(signingKeyId),
};
}
export function buildProvisionRequest(validated, idempotencyKey, approvalEnvelope) {
if (!object(approvalEnvelope)) throw new Error("approval_envelope_missing");
return {
...validated.plan,
idempotency_key: string(idempotencyKey),
approval_envelope: approvalEnvelope,
owner_provenance_id: validated.approval.owner_provenance_id,
};
}
export function validateProvisionResponse(response, expected, now = Date.now()) {
const value = object(response);
if (!value || containsSecretField(value)) return { ok: false, code: "broker_response_contains_secret_field" };
if (value.simulation !== false || value.status !== "provisioned") {
return { ok: false, code: "broker_response_invalid" };
}
if (value.broker_session_verified !== true ||
value.openclaw_native_child_session_verified !== false ||
value.codex_child_session_verified !== false) {
return { ok: false, code: "broker_response_session_type_invalid" };
}
for (const field of ["child_session_id", "worker_id", "execution_endpoint_id", "worktree_id"]) {
if (!string(value[field])) return { ok: false, code: "broker_response_invalid" };
}
if (string(value.task_id) !== expected.task_id ||
string(value.bound_branch) !== expected.branch ||
string(value.bound_base_oid).toLowerCase() !== expected.approved_base_oid) {
return { ok: false, code: "broker_response_mismatch" };
}
const capabilities = exactStringArray(value.capabilities, "broker_response_capabilities");
if (!capabilities.ok || !sameSet(capabilities.values, expected.required_capabilities)) {
return { ok: false, code: "broker_response_capability_mismatch" };
}
const credentialReference = string(value.credential_reference);
if (!credentialReference.startsWith("secret-ref://")) {
return { ok: false, code: "broker_response_credential_reference_invalid" };
}
const expiry = parseIso(value.expires_at, "broker_response_expires_at");
if (!expiry.ok || expiry.timestamp <= now) return { ok: false, code: "broker_response_expired" };
return {
ok: true,
value: {
simulation: false,
status: "provisioned",
broker_session_verified: true,
openclaw_native_child_session_verified: false,
codex_child_session_verified: false,
child_session_id: string(value.child_session_id),
worker_id: string(value.worker_id),
task_id: string(value.task_id),
execution_endpoint_id: string(value.execution_endpoint_id),
credential_reference: credentialReference,
bound_branch: string(value.bound_branch),
bound_base_oid: string(value.bound_base_oid).toLowerCase(),
worktree_id: string(value.worktree_id),
expires_at: string(value.expires_at),
capabilities: capabilities.values,
},
};
}
export const __test = { containsSecretField, sameSet };
@@ -0,0 +1,32 @@
export const DISPATCH_ERROR_CODES = {
protectedDispatchTransportNotAuthorized: "protected_dispatch_transport_not_authorized",
taskPlanMissing: "protected_dispatch_task_plan_missing",
taskPlanInvalid: "protected_dispatch_task_plan_invalid",
taskPlanBranchRejected: "task_plan_branch_rejected",
taskPlanBaseInvalid: "task_plan_base_invalid",
taskPlanPathRejected: "task_plan_path_rejected",
taskPlanCapabilityMismatch: "task_plan_capability_mismatch",
taskPlanApprovalMissing: "task_plan_approval_missing",
taskPlanApprovalExpired: "task_plan_approval_expired",
taskPlanApprovalTaskMismatch: "task_plan_approval_task_mismatch",
brokerConfigInvalid: "broker_config_invalid",
brokerCredentialReadFailed: "broker_credential_read_failed",
brokerCredentialInvalid: "broker_credential_invalid",
brokerRequestFailed: "broker_request_failed",
brokerUnavailable: "broker_unreachable",
brokerResponseInvalid: "broker_response_invalid",
brokerResponseMismatch: "broker_response_mismatch",
brokerResponseContainsSecret: "broker_response_contains_secret_field",
transportExecutionFailed: "protected_dispatch_transport_failed",
};
export function dispatchError(code) {
const error = new Error(code);
error.code = code;
error.aiosDispatchError = true;
return error;
}
export function isDispatchError(error) {
return error instanceof Error && error.aiosDispatchError === true && typeof error.code === "string";
}
+45
View File
@@ -0,0 +1,45 @@
import fs from "node:fs";
import path from "node:path";
function redacted(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const scrubbed = {};
for (const [key, item] of Object.entries(value)) {
if (key === "token" || key === "authorization" || key === "secret" || key === "credential") {
continue;
}
if (item && typeof item === "object") {
scrubbed[key] = redacted(item);
} else if (typeof item === "string" && ["x-aios-token", "x-api-token", "authorization"].includes(key.toLowerCase())) {
scrubbed[key] = "[redacted]";
} else {
scrubbed[key] = item;
}
}
return scrubbed;
}
function appendRecord(auditPath, record) {
const parent = path.dirname(auditPath);
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
const fd = fs.openSync(auditPath, "a", 0o640);
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.chmodSync(auditPath, 0o640);
}
export function appendDispatchAudit(auditPath, record) {
if (!auditPath) return;
const absolute = path.isAbsolute(auditPath);
if (!absolute) throw new Error("invalid_audit_path");
const payload = {
schema_version: "aios.protected-dispatch-audit/v1",
recorded_at: Date.now(),
...(redacted(record) || {}),
};
appendRecord(auditPath, payload);
}
+300
View File
@@ -0,0 +1,300 @@
import crypto from "node:crypto";
import { canonicalize } from "./provenance.js";
const FORBIDDEN_BRANCHES = new Set(["main", "master", "trunk"]);
const OID_RE = /^[0-9a-f]{40}$/;
const BRANCH_RE = /^(?!\/)(?!.*(?:\.\.|@\{|\/\/|[~^:?*[\]\\\s]))(?!.*\/$)[A-Za-z0-9._/-]{1,240}$/;
const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RECIPIENT_BINDING_RE = /^principal:sha256:[0-9a-f]{64}$/;
const SECRET_KEYS = /^(?:token|access_token|authorization|authorization_header|credential|credential_value|secret|secret_value)$/i;
const DEFAULT_CAPABILITIES = Object.freeze([
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
]);
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function string(value) {
return typeof value === "string" ? value.trim() : "";
}
function exactStringArray(value, field, { max = 64 } = {}) {
if (!Array.isArray(value) || value.length === 0 || value.length > max) {
return { ok: false, code: `${field}_invalid` };
}
const values = value.map(string);
if (values.some((item) => !item) || new Set(values).size !== values.length) {
return { ok: false, code: `${field}_invalid` };
}
return { ok: true, values };
}
function sameSet(left, right) {
return left.length === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
}
function parseIso(value, field) {
const timestamp = Date.parse(string(value));
return Number.isFinite(timestamp) ? { ok: true, timestamp } : { ok: false, code: `${field}_invalid` };
}
function containsSecretField(value) {
if (Array.isArray(value)) return value.some(containsSecretField);
if (!object(value)) return false;
return Object.entries(value).some(([key, item]) => SECRET_KEYS.test(key) || containsSecretField(item));
}
export function deriveRequiredCapabilities(action) {
if (!new Set(["candidate.session.provision", "candidate.repository.change"]).has(string(action))) {
return { ok: false, code: "task_plane_action_not_allowed" };
}
return { ok: true, capabilities: [...DEFAULT_CAPABILITIES] };
}
export function taskPlanDigest(taskPlan) {
return crypto.createHash("sha256").update(canonicalize(taskPlan)).digest("hex");
}
export function validateTaskPlaneRequest({ request, permit, config = {}, now = Date.now() }) {
const payload = object(request?.payload);
const plan = object(payload?.task_plan);
const approval = object(payload?.approval_record);
if (!plan) return { ok: false, code: "protected_dispatch_task_plan_missing" };
if (!approval) return { ok: false, code: "task_plan_approval_missing" };
const expectedPlanKeys = [
"task_id", "parent_session_id", "requested_role", "required_capabilities",
"repository", "branch", "approved_base_oid", "allowed_paths", "credential_policy",
"result_delivery",
].sort();
const actualPlanKeys = Object.keys(plan).sort();
if (actualPlanKeys.length !== expectedPlanKeys.length || expectedPlanKeys.some((key) => !actualPlanKeys.includes(key))) {
return { ok: false, code: "task_plan_schema_mismatch" };
}
const taskId = string(plan.task_id);
if (!TASK_ID_RE.test(taskId) || taskId !== string(request.task_id) || taskId !== string(permit?.record?.payload?.task_id)) {
return { ok: false, code: "task_plan_task_mismatch" };
}
const parentSessionId = string(plan.parent_session_id);
if (!parentSessionId) return { ok: false, code: "parent_session_id_required" };
if (string(plan.requested_role) !== "dedicated_platform_writer") {
return { ok: false, code: "requested_role_rejected" };
}
const repository = string(plan.repository);
const branch = string(plan.branch);
const baseOid = string(plan.approved_base_oid).toLowerCase();
if (!REPOSITORY_RE.test(repository)) return { ok: false, code: "repository_invalid" };
if (!BRANCH_RE.test(branch) || FORBIDDEN_BRANCHES.has(branch.toLowerCase())) {
return { ok: false, code: "task_plan_branch_rejected" };
}
if (!OID_RE.test(baseOid)) return { ok: false, code: "task_plan_base_invalid" };
for (const [actual, expected, code] of [
[taskId, string(config.taskPlaneTaskId), "task_plan_task_not_configured"],
[repository, string(config.taskPlaneRepository), "task_plan_repository_mismatch"],
[branch, string(config.taskPlaneBranch), "task_plan_branch_mismatch"],
[baseOid, string(config.taskPlaneApprovedBaseOid).toLowerCase(), "task_plan_base_mismatch"],
]) {
if (!expected || actual !== expected) return { ok: false, code };
}
const pathCheck = exactStringArray(plan.allowed_paths, "task_plan_allowed_paths");
if (!pathCheck.ok) return pathCheck;
const configuredPaths = Array.isArray(config.taskPlaneAllowedPaths) ? config.taskPlaneAllowedPaths.map(string) : [];
if (!sameSet(pathCheck.values, configuredPaths)) return { ok: false, code: "task_plan_path_rejected" };
const derived = deriveRequiredCapabilities(request.action);
if (!derived.ok) return derived;
const capabilityCheck = exactStringArray(plan.required_capabilities, "task_plan_required_capabilities");
if (!capabilityCheck.ok) return capabilityCheck;
const configuredCapabilities = Array.isArray(config.taskPlaneAllowedCapabilities)
? config.taskPlaneAllowedCapabilities.map(string)
: [...DEFAULT_CAPABILITIES];
if (!sameSet(derived.capabilities, configuredCapabilities) || !sameSet(capabilityCheck.values, derived.capabilities)) {
return { ok: false, code: "task_plan_capability_mismatch" };
}
const credentialPolicy = object(plan.credential_policy);
if (!credentialPolicy || Object.keys(credentialPolicy).sort().join(",") !== "max_uses,ttl_seconds") {
return { ok: false, code: "credential_policy_invalid" };
}
const ttlSeconds = Number(credentialPolicy.ttl_seconds);
const maxUses = Number(credentialPolicy.max_uses);
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 1800 ||
!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 100) {
return { ok: false, code: "credential_policy_invalid" };
}
const resultDelivery = object(plan.result_delivery);
const expectedResultDeliveryKeys = [
"channel", "recipient_binding", "content_type", "encoding", "image_only",
].sort();
if (!resultDelivery ||
Object.keys(resultDelivery).sort().join(",") !== expectedResultDeliveryKeys.join(",") ||
string(resultDelivery.channel) !== "supervisor" ||
string(resultDelivery.content_type) !== "text/plain" ||
string(resultDelivery.encoding).toLowerCase() !== "utf-8" ||
resultDelivery.image_only !== false ||
!RECIPIENT_BINDING_RE.test(string(resultDelivery.recipient_binding)) ||
string(resultDelivery.recipient_binding) !== string(config.taskPlaneResultRecipientBinding)) {
return { ok: false, code: "task_plan_result_delivery_rejected" };
}
const expectedApprovalKeys = [
"approval_id", "task_id", "decision", "policy_id", "owner_principal_id",
"task_plan_sha256", "approved_capabilities", "not_before", "expires_at",
].sort();
const actualApprovalKeys = Object.keys(approval).sort();
if (actualApprovalKeys.length !== expectedApprovalKeys.length ||
expectedApprovalKeys.some((key) => !actualApprovalKeys.includes(key))) {
return { ok: false, code: "approval_record_schema_mismatch" };
}
if (!string(approval.approval_id) || string(approval.task_id) !== taskId ||
string(approval.decision) !== "approved" ||
string(approval.policy_id) !== string(config.taskPlanePolicyId) ||
string(approval.owner_principal_id) !== string(permit.record.payload.principal_id) ||
string(approval.task_plan_sha256) !== taskPlanDigest(plan)) {
return { ok: false, code: "approval_record_mismatch" };
}
const approvalCapabilities = exactStringArray(approval.approved_capabilities, "approval_capabilities");
if (!approvalCapabilities.ok || !sameSet(approvalCapabilities.values, derived.capabilities)) {
return { ok: false, code: "task_plan_capability_mismatch" };
}
const notBefore = parseIso(approval.not_before, "approval_not_before");
const expiresAt = parseIso(approval.expires_at, "approval_expires_at");
if (!notBefore.ok) return notBefore;
if (!expiresAt.ok) return expiresAt;
if (notBefore.timestamp > now || expiresAt.timestamp <= now || expiresAt.timestamp - notBefore.timestamp > 1_800_000) {
return { ok: false, code: "task_plan_approval_expired" };
}
return {
ok: true,
plan: {
task_id: taskId,
parent_session_id: parentSessionId,
requested_role: "dedicated_platform_writer",
required_capabilities: derived.capabilities,
repository,
branch,
approved_base_oid: baseOid,
allowed_paths: pathCheck.values,
credential_policy: { ttl_seconds: ttlSeconds, max_uses: maxUses },
result_delivery: {
channel: "supervisor",
recipient_binding: string(resultDelivery.recipient_binding),
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
},
},
approval: {
approval_plan_id: string(approval.approval_id),
policy_id: string(approval.policy_id),
owner_provenance_id: string(permit.record.payload.provenance_id),
task_plan_sha256: string(approval.task_plan_sha256),
not_before: string(approval.not_before),
expires_at: string(approval.expires_at),
},
};
}
export function buildApprovalPayload(
validated,
{ idempotencyKey, riskClass, signingKeyId },
) {
const approvalPlanId = validated.approval.approval_plan_id;
const nonceSeed = canonicalize({
approval_plan_id: approvalPlanId,
task_id: validated.plan.task_id,
idempotency_key: string(idempotencyKey),
});
return {
approval_plan_id: approvalPlanId,
task_id: validated.plan.task_id,
task_plan_sha256: validated.approval.task_plan_sha256,
repository: validated.plan.repository,
branch: validated.plan.branch,
approved_base_oid: validated.plan.approved_base_oid,
allowed_paths: [...validated.plan.allowed_paths],
approved_capabilities: [...validated.plan.required_capabilities],
risk_class: string(riskClass),
not_before: validated.approval.not_before,
expires_at: validated.approval.expires_at,
nonce: `approval-nonce-v1:${crypto.createHash("sha256").update(nonceSeed).digest("hex")}`,
signing_key_id: string(signingKeyId),
};
}
export function buildProvisionRequest(validated, idempotencyKey, approvalEnvelope) {
if (!object(approvalEnvelope)) throw new Error("approval_envelope_missing");
return {
...validated.plan,
idempotency_key: string(idempotencyKey),
approval_envelope: approvalEnvelope,
owner_provenance_id: validated.approval.owner_provenance_id,
};
}
export function validateProvisionResponse(response, expected, now = Date.now()) {
const value = object(response);
if (!value || containsSecretField(value)) return { ok: false, code: "broker_response_contains_secret_field" };
if (value.simulation !== false || value.status !== "provisioned") {
return { ok: false, code: "broker_response_invalid" };
}
if (value.broker_session_verified !== true ||
value.openclaw_native_child_session_verified !== false ||
value.codex_child_session_verified !== false) {
return { ok: false, code: "broker_response_session_type_invalid" };
}
for (const field of ["child_session_id", "worker_id", "execution_endpoint_id", "worktree_id"]) {
if (!string(value[field])) return { ok: false, code: "broker_response_invalid" };
}
if (string(value.task_id) !== expected.task_id ||
string(value.bound_branch) !== expected.branch ||
string(value.bound_base_oid).toLowerCase() !== expected.approved_base_oid) {
return { ok: false, code: "broker_response_mismatch" };
}
const capabilities = exactStringArray(value.capabilities, "broker_response_capabilities");
if (!capabilities.ok || !sameSet(capabilities.values, expected.required_capabilities)) {
return { ok: false, code: "broker_response_capability_mismatch" };
}
const credentialReference = string(value.credential_reference);
if (!credentialReference.startsWith("secret-ref://")) {
return { ok: false, code: "broker_response_credential_reference_invalid" };
}
const expiry = parseIso(value.expires_at, "broker_response_expires_at");
if (!expiry.ok || expiry.timestamp <= now) return { ok: false, code: "broker_response_expired" };
return {
ok: true,
value: {
simulation: false,
status: "provisioned",
broker_session_verified: true,
openclaw_native_child_session_verified: false,
codex_child_session_verified: false,
child_session_id: string(value.child_session_id),
worker_id: string(value.worker_id),
task_id: string(value.task_id),
execution_endpoint_id: string(value.execution_endpoint_id),
credential_reference: credentialReference,
bound_branch: string(value.bound_branch),
bound_base_oid: string(value.bound_base_oid).toLowerCase(),
worktree_id: string(value.worktree_id),
expires_at: string(value.expires_at),
capabilities: capabilities.values,
},
};
}
export const __test = { containsSecretField, sameSet };
+32
View File
@@ -0,0 +1,32 @@
export const DISPATCH_ERROR_CODES = {
protectedDispatchTransportNotAuthorized: "protected_dispatch_transport_not_authorized",
taskPlanMissing: "protected_dispatch_task_plan_missing",
taskPlanInvalid: "protected_dispatch_task_plan_invalid",
taskPlanBranchRejected: "task_plan_branch_rejected",
taskPlanBaseInvalid: "task_plan_base_invalid",
taskPlanPathRejected: "task_plan_path_rejected",
taskPlanCapabilityMismatch: "task_plan_capability_mismatch",
taskPlanApprovalMissing: "task_plan_approval_missing",
taskPlanApprovalExpired: "task_plan_approval_expired",
taskPlanApprovalTaskMismatch: "task_plan_approval_task_mismatch",
brokerConfigInvalid: "broker_config_invalid",
brokerCredentialReadFailed: "broker_credential_read_failed",
brokerCredentialInvalid: "broker_credential_invalid",
brokerRequestFailed: "broker_request_failed",
brokerUnavailable: "broker_unreachable",
brokerResponseInvalid: "broker_response_invalid",
brokerResponseMismatch: "broker_response_mismatch",
brokerResponseContainsSecret: "broker_response_contains_secret_field",
transportExecutionFailed: "protected_dispatch_transport_failed",
};
export function dispatchError(code) {
const error = new Error(code);
error.code = code;
error.aiosDispatchError = true;
return error;
}
export function isDispatchError(error) {
return error instanceof Error && error.aiosDispatchError === true && typeof error.code === "string";
}
+480
View File
@@ -0,0 +1,480 @@
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";
import { appendDispatchAudit } from "./dispatch-audit.js";
import { dispatchError, isDispatchError } from "./dispatch-errors.js";
import { dispatchMockTransport } from "./transports/mock.js";
import { dispatchSandboxTransport } from "./transports/sandbox.js";
import { dispatchTaskPlaneTransport } from "./transports/task-plane.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";
const PROTECTED_DISPATCH_TRANSPORTS = Object.freeze({
mock: dispatchMockTransport,
sandbox: dispatchSandboxTransport,
"task-plane": dispatchTaskPlaneTransport,
});
function pluginConfig(event, api) {
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
}
function protectedDispatchProvider(config = {}) {
return String(config.protectedDispatchProvider || DEFAULT_PROTECTED_DISPATCH_PROVIDER);
}
function protectedDispatchTransport(config = {}) {
return String(config.protectedDispatchTransport || "disabled");
}
function protectedDispatchTransportConfig(config = {}) {
return PROTECTED_DISPATCH_TRANSPORTS[protectedDispatchTransport(config)];
}
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);
}
function appendProtectedDispatchAudit(config, details) {
const auditPath = String(config.protectedDispatchAuditPath || "");
if (!auditPath) return;
if (!path.isAbsolute(auditPath)) return;
appendDispatchAudit(auditPath, details);
}
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);
const expected = new Set(["mock", "sandbox", "task-plane"]);
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 transport = protectedDispatchTransportConfig(config);
const mode = protectedDispatchTransport(config);
if (!expected.has(mode)) {
throw dispatchError("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 && mode === "mock" && attempts === 1) {
throw new Error("simulated_delivery_failure");
}
try {
const result = await transport({
permit,
params,
request,
config,
attempts,
mode,
});
if (!result.receipt_sha256) {
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
}
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
appendProtectedDispatchAudit(config, {
transport: mode,
task_id: result.task_id,
tenant_id: permit.record.payload.tenant_id,
principal_id: permit.record.payload.principal_id,
provenance_id: permit.record.payload.provenance_id,
simulation: result.simulation,
status: result.status || "accepted",
branch: result.bound_branch,
base: result.bound_base_oid,
paths: permit.request.payload?.task_plan?.allowed_paths,
capabilities: result.capabilities,
request_idempotency_key: request.idempotency_key,
});
return toolResult(result);
} catch (error) {
appendProtectedDispatchAudit(config, {
transport: mode,
task_id: request.task_id,
tenantId: permit.record.payload.tenant_id,
principal_id: permit.record.payload.principal_id,
provenance_id: permit.record.payload.provenance_id,
simulation: false,
request_idempotency_key: request.idempotency_key,
transport_error: isDispatchError(error)
? error.code
: error instanceof Error ? error.message : "transport_error",
status_code: isDispatchError(error) ? error.code : "transport_error",
});
if (isDispatchError(error)) throw error;
const thrown = error instanceof Error ? error : dispatchError("protected_dispatch_transport_failed");
throw thrown;
}
},
}), { 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 };
+1
View File
@@ -0,0 +1 @@
export * from "../policy.js";
+1
View File
@@ -0,0 +1 @@
export * from "../provenance.js";
@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
const ALLOWED_BROKER_HOSTS = new Set(["127.0.0.1", "localhost"]);
const DEFAULT_TIMEOUT_MS = 3000;
export function brokerCredentialFromPath(credentialPath) {
if (!path.isAbsolute(credentialPath)) throw new Error("broker_credential_path_invalid");
const stat = fs.lstatSync(credentialPath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("broker_credential_path_invalid");
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
throw new Error("broker_credential_permissions_invalid");
}
const token = fs.readFileSync(credentialPath, "utf8").trim();
if (!token) throw new Error("broker_credential_empty");
return token;
}
export function assertLocalBrokerEndpoint(endpoint) {
const safe = new URL(endpoint);
if (safe.protocol !== "http:") throw new Error("broker_endpoint_protocol_invalid");
if (!ALLOWED_BROKER_HOSTS.has(safe.hostname)) throw new Error("broker_endpoint_host_invalid");
if (safe.username || safe.password || safe.search || safe.hash) throw new Error("broker_endpoint_invalid");
if (safe.pathname === "/") safe.pathname = "/v1/sessions/provision";
if (safe.pathname !== "/v1/sessions/provision") throw new Error("broker_endpoint_path_invalid");
return safe;
}
export async function callBrokerProvision({
endpoint,
credentialPath,
payload,
timeoutMs = DEFAULT_TIMEOUT_MS,
}) {
const brokerUrl = assertLocalBrokerEndpoint(endpoint);
const credential = brokerCredentialFromPath(credentialPath);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.min(Math.max(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, 500), 15_000));
try {
const response = await fetch(brokerUrl, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${credential}`,
},
body: JSON.stringify(payload),
});
const responseBody = await response.text();
let body;
try {
body = responseBody ? JSON.parse(responseBody) : {};
} catch {
throw new Error("broker_response_not_json");
}
if (!response.ok) {
const reason = typeof body?.error === "string" ? body.error : `http_${response.status}`;
const error = new Error(reason);
error.code = "broker_http_error";
error.status = response.status;
throw error;
}
return body;
} catch (error) {
if (error.name === "AbortError") {
const timeout = new Error("broker_timeout");
timeout.code = "broker_timeout";
throw timeout;
}
if (error.code) throw error;
const transport = new Error("broker_unreachable");
transport.code = "broker_unreachable";
throw transport;
} finally {
clearTimeout(timer);
}
}
+20
View File
@@ -0,0 +1,20 @@
import crypto from "node:crypto";
import { canonicalize } from "../provenance.js";
export function dispatchMockTransport({ permit, params, attempts, mode }) {
const result = {
accepted: true,
task_id: permit.request.task_id,
provider: permit.request.provider,
provider_source: "server_config",
provider_hint_overridden: params.provider !== permit.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");
return result;
}
@@ -0,0 +1,5 @@
import { dispatchMockTransport } from "./mock.js";
export function dispatchSandboxTransport(context) {
return dispatchMockTransport({ ...context, mode: "sandbox" });
}
@@ -0,0 +1,172 @@
import crypto from "node:crypto";
import net from "node:net";
import {
buildApprovalPayload,
buildProvisionRequest,
validateProvisionResponse,
validateTaskPlaneRequest,
} from "../dispatch-contract.js";
import { callBrokerProvision } from "../session-broker-client.js";
import { dispatchError } from "../dispatch-errors.js";
import { canonicalize } from "../provenance.js";
const DEFAULT_SIGNER_SOCKET = "/run/aios-owner-approval-signer-candidate/signer.sock";
const MAX_SIGNER_RESPONSE_BYTES = 256 * 1024;
function exactObject(value, keys) {
return value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).sort().join(",") === [...keys].sort().join(",");
}
function callOwnerApprovalSigner({ socketPath, request, timeoutMs }) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ path: socketPath });
let settled = false;
let response = "";
const finish = (error, value) => {
if (settled) return;
settled = true;
socket.destroy();
if (error) reject(error);
else resolve(value);
};
socket.setEncoding("utf8");
socket.setTimeout(timeoutMs);
socket.on("connect", () => socket.end(`${canonicalize(request)}\n`));
socket.on("data", (chunk) => {
response += chunk;
if (Buffer.byteLength(response, "utf8") > MAX_SIGNER_RESPONSE_BYTES) {
finish(new Error("owner_signer_response_too_large"));
}
});
socket.on("timeout", () => finish(new Error("owner_signer_timeout")));
socket.on("error", (error) => finish(error));
socket.on("end", () => {
try {
const line = response.trim();
finish(null, JSON.parse(line));
} catch (error) {
finish(error);
}
});
});
}
async function buildApprovalEnvelope(validated, config, idempotencyKey) {
const socketPath = config.taskPlaneOwnerApprovalSignerSocketPath || DEFAULT_SIGNER_SOCKET;
const keyId = config.taskPlaneApprovalSigningKeyId;
const riskClass = config.taskPlaneApprovalRiskClass;
if (typeof socketPath !== "string" || !socketPath.startsWith("/") ||
typeof keyId !== "string" || !keyId ||
typeof riskClass !== "string" || !riskClass) {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
const payload = buildApprovalPayload(validated, {
idempotencyKey,
riskClass,
signingKeyId: keyId,
});
const signerRequest = {
schema_version: "aios.owner-approval-sign-request.v1",
operation: "sign_owner_approval",
approval_plan_id: validated.approval.approval_plan_id,
signing_key_id: keyId,
canonical_payload: canonicalize(payload),
approval_payload: payload,
};
let envelope;
try {
envelope = await callOwnerApprovalSigner({
socketPath,
request: signerRequest,
timeoutMs: config.protectedDispatchBrokerTimeoutMs || 5_000,
});
} catch {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
if (!exactObject(envelope, [
"approval_plan_id", "approval_key_id", "approval_signature", "approval_payload",
]) ||
envelope.approval_plan_id !== validated.approval.approval_plan_id ||
envelope.approval_key_id !== keyId ||
typeof envelope.approval_signature !== "string" ||
!envelope.approval_signature ||
canonicalize(envelope.approval_payload) !== signerRequest.canonical_payload) {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
return envelope;
}
function buildSupervisorTextResult({ request, validated, checked }) {
const content = [
"AIOS Candidate Session Broker result",
`task_id=${request.task_id}`,
`broker_session_id=${checked.value.child_session_id}`,
"broker_session_verified=true",
"openclaw_native_child_session_verified=false",
"codex_child_session_verified=false",
].join("\n");
const messageSeed = canonicalize({
task_id: request.task_id,
idempotency_key: request.idempotency_key,
child_session_id: checked.value.child_session_id,
recipient_binding: validated.plan.result_delivery.recipient_binding,
});
const messageId = `message-${crypto.createHash("sha256").update(messageSeed).digest("hex").slice(0, 32)}`;
const result = {
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
task_id: request.task_id,
message_id: messageId,
recipient_binding: validated.plan.result_delivery.recipient_binding,
content,
secrets_included: false,
};
result.receipt = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
return result;
}
export async function dispatchTaskPlaneTransport({ permit, request, params, config, attempts }) {
const validated = validateTaskPlaneRequest({ request, permit, config });
if (!validated.ok) throw dispatchError(validated.code);
if (typeof config.protectedDispatchBrokerEndpoint !== "string" ||
typeof config.protectedDispatchBrokerCredentialPath !== "string") {
throw dispatchError("broker_config_invalid");
}
const approvalEnvelope = await buildApprovalEnvelope(validated, config, request.idempotency_key);
const provisionRequest = buildProvisionRequest(validated, request.idempotency_key, approvalEnvelope);
let response;
try {
response = await callBrokerProvision({
endpoint: config.protectedDispatchBrokerEndpoint,
credentialPath: config.protectedDispatchBrokerCredentialPath,
payload: provisionRequest,
timeoutMs: config.protectedDispatchBrokerTimeoutMs,
});
} catch (error) {
throw dispatchError(error instanceof Error && error.code ? error.code : "broker_unreachable");
}
const checked = validateProvisionResponse(response, validated.plan);
if (!checked.ok) throw dispatchError(checked.code);
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: false,
transport: "task-plane",
real_user_impersonated: false,
attempts,
...checked.value,
};
result.supervisor_text_result = buildSupervisorTextResult({ request, validated, checked });
result.plain_text_result_delivery = true;
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
return result;
}
+1
View File
@@ -0,0 +1 @@
export * from "../writer.js";
+480
View File
@@ -0,0 +1,480 @@
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";
import { appendDispatchAudit } from "./dispatch-audit.js";
import { dispatchError, isDispatchError } from "./dispatch-errors.js";
import { dispatchMockTransport } from "./transports/mock.js";
import { dispatchSandboxTransport } from "./transports/sandbox.js";
import { dispatchTaskPlaneTransport } from "./transports/task-plane.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";
const PROTECTED_DISPATCH_TRANSPORTS = Object.freeze({
mock: dispatchMockTransport,
sandbox: dispatchSandboxTransport,
"task-plane": dispatchTaskPlaneTransport,
});
function pluginConfig(event, api) {
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
}
function protectedDispatchProvider(config = {}) {
return String(config.protectedDispatchProvider || DEFAULT_PROTECTED_DISPATCH_PROVIDER);
}
function protectedDispatchTransport(config = {}) {
return String(config.protectedDispatchTransport || "disabled");
}
function protectedDispatchTransportConfig(config = {}) {
return PROTECTED_DISPATCH_TRANSPORTS[protectedDispatchTransport(config)];
}
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);
}
function appendProtectedDispatchAudit(config, details) {
const auditPath = String(config.protectedDispatchAuditPath || "");
if (!auditPath) return;
if (!path.isAbsolute(auditPath)) return;
appendDispatchAudit(auditPath, details);
}
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);
const expected = new Set(["mock", "sandbox", "task-plane"]);
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 transport = protectedDispatchTransportConfig(config);
const mode = protectedDispatchTransport(config);
if (!expected.has(mode)) {
throw dispatchError("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 && mode === "mock" && attempts === 1) {
throw new Error("simulated_delivery_failure");
}
try {
const result = await transport({
permit,
params,
request,
config,
attempts,
mode,
});
if (!result.receipt_sha256) {
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
}
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
appendProtectedDispatchAudit(config, {
transport: mode,
task_id: result.task_id,
tenant_id: permit.record.payload.tenant_id,
principal_id: permit.record.payload.principal_id,
provenance_id: permit.record.payload.provenance_id,
simulation: result.simulation,
status: result.status || "accepted",
branch: result.bound_branch,
base: result.bound_base_oid,
paths: permit.request.payload?.task_plan?.allowed_paths,
capabilities: result.capabilities,
request_idempotency_key: request.idempotency_key,
});
return toolResult(result);
} catch (error) {
appendProtectedDispatchAudit(config, {
transport: mode,
task_id: request.task_id,
tenantId: permit.record.payload.tenant_id,
principal_id: permit.record.payload.principal_id,
provenance_id: permit.record.payload.provenance_id,
simulation: false,
request_idempotency_key: request.idempotency_key,
transport_error: isDispatchError(error)
? error.code
: error instanceof Error ? error.message : "transport_error",
status_code: isDispatchError(error) ? error.code : "transport_error",
});
if (isDispatchError(error)) throw error;
const thrown = error instanceof Error ? error : dispatchError("protected_dispatch_transport_failed");
throw thrown;
}
},
}), { 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 };
+124
View File
@@ -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,66 @@
{
"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", "task-plane"], "default": "disabled" },
"protectedDispatchBrokerEndpoint": { "type": "string" },
"protectedDispatchBrokerCredentialPath": { "type": "string" },
"protectedDispatchBrokerTimeoutMs": { "type": "integer", "minimum": 250, "maximum": 15000 },
"protectedDispatchAuditPath": { "type": "string" },
"taskPlaneTaskId": { "type": "string" },
"taskPlanePolicyId": { "type": "string" },
"taskPlaneRepository": { "type": "string" },
"taskPlaneBranch": { "type": "string" },
"taskPlaneApprovedBaseOid": { "type": "string" },
"taskPlaneAllowedPaths": { "type": "array", "items": { "type": "string" } },
"taskPlaneAllowedCapabilities": { "type": "array", "items": { "type": "string" } },
"taskPlaneOwnerApprovalSignerSocketPath": { "type": "string", "default": "/run/aios-owner-approval-signer-candidate/signer.sock" },
"taskPlaneApprovalSigningKeyId": { "type": "string" },
"taskPlaneApprovalRiskClass": { "type": "string", "default": "high" },
"taskPlaneResultRecipientBinding": { "type": "string" },
"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 }
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"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",
"dispatch-contract.js",
"dispatch-audit.js",
"dispatch-errors.js",
"session-broker-client.js",
"transports",
"dist",
"openclaw.plugin.json"
],
"scripts": {
"test": "node --test test/*.test.js"
},
"openclaw": {
"extensions": [
"./dist/index.js"
]
}
}
+154
View File
@@ -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 };
}
+234
View File
@@ -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,
});
}
+280
View File
@@ -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,77 @@
import fs from "node:fs";
import path from "node:path";
const ALLOWED_BROKER_HOSTS = new Set(["127.0.0.1", "localhost"]);
const DEFAULT_TIMEOUT_MS = 3000;
export function brokerCredentialFromPath(credentialPath) {
if (!path.isAbsolute(credentialPath)) throw new Error("broker_credential_path_invalid");
const stat = fs.lstatSync(credentialPath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("broker_credential_path_invalid");
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
throw new Error("broker_credential_permissions_invalid");
}
const token = fs.readFileSync(credentialPath, "utf8").trim();
if (!token) throw new Error("broker_credential_empty");
return token;
}
export function assertLocalBrokerEndpoint(endpoint) {
const safe = new URL(endpoint);
if (safe.protocol !== "http:") throw new Error("broker_endpoint_protocol_invalid");
if (!ALLOWED_BROKER_HOSTS.has(safe.hostname)) throw new Error("broker_endpoint_host_invalid");
if (safe.username || safe.password || safe.search || safe.hash) throw new Error("broker_endpoint_invalid");
if (safe.pathname === "/") safe.pathname = "/v1/sessions/provision";
if (safe.pathname !== "/v1/sessions/provision") throw new Error("broker_endpoint_path_invalid");
return safe;
}
export async function callBrokerProvision({
endpoint,
credentialPath,
payload,
timeoutMs = DEFAULT_TIMEOUT_MS,
}) {
const brokerUrl = assertLocalBrokerEndpoint(endpoint);
const credential = brokerCredentialFromPath(credentialPath);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.min(Math.max(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, 500), 15_000));
try {
const response = await fetch(brokerUrl, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${credential}`,
},
body: JSON.stringify(payload),
});
const responseBody = await response.text();
let body;
try {
body = responseBody ? JSON.parse(responseBody) : {};
} catch {
throw new Error("broker_response_not_json");
}
if (!response.ok) {
const reason = typeof body?.error === "string" ? body.error : `http_${response.status}`;
const error = new Error(reason);
error.code = "broker_http_error";
error.status = response.status;
throw error;
}
return body;
} catch (error) {
if (error.name === "AbortError") {
const timeout = new Error("broker_timeout");
timeout.code = "broker_timeout";
throw timeout;
}
if (error.code) throw error;
const transport = new Error("broker_unreachable");
transport.code = "broker_unreachable";
throw transport;
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,428 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import plugin, { __state } from "../index.js";
import { taskPlanDigest } from "../dispatch-contract.js";
import { assertLocalBrokerEndpoint } from "../session-broker-client.js";
const BRANCH = "patch/openclaw-real-dispatch-session-broker-20260725";
const BASE = "95b3728abde86aebda60666f97bcd0576d87307a";
const ALLOWED_PATH = "/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725";
const CAPABILITIES = [
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
];
const OWNER_PRINCIPAL = `principal:sha256:${crypto.createHash("sha256").update("synthetic-owner").digest("hex")}`;
function keyFixture() {
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
return {
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
publicPem: publicKey.export({ format: "pem", type: "spki" }),
};
}
function fixture({ mode = "mock", taskId = "TASK-TEST" } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-dispatch-"));
const keys = keyFixture();
const privateKeyPath = path.join(root, "private.bin");
const publicKeyPath = path.join(root, "public.pem");
const credentialPath = path.join(root, "broker.secret");
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
fs.writeFileSync(credentialPath, "candidate-broker-test-token", { mode: 0o600 });
const hooks = {};
const factories = {};
const api = {
pluginConfig: {
runtimeStatePath: path.join(root, "runtime.jsonl"),
runtimeSigningPublicKeyPath: publicKeyPath,
runtimeSigningKeyId: "owner-key-1",
runtimeCredentialRef: "secret-ref://test/runtime",
provenanceStoreDir: path.join(root, "store"),
provenancePrivateKeyPath: privateKeyPath,
provenancePublicKeyPath: publicKeyPath,
provenanceKeyId: "owner-key-1",
tenantId: "candidate-tenant",
protectedDispatchProvider: "chatgpt-supervisor",
protectedDispatchTransport: mode,
protectedDispatchBrokerCredentialPath: credentialPath,
protectedDispatchAuditPath: path.join(root, "dispatch-audit.jsonl"),
protectedDispatchBrokerTimeoutMs: 1000,
taskPlaneTaskId: taskId,
taskPlanePolicyId: "token-policy-v2:candidate-session-broker",
taskPlaneRepository: "aios/aios",
taskPlaneBranch: BRANCH,
taskPlaneApprovedBaseOid: BASE,
taskPlaneAllowedPaths: [ALLOWED_PATH],
taskPlaneAllowedCapabilities: CAPABILITIES,
taskPlaneOwnerApprovalSignerSocketPath: path.join(root, "owner-signer.sock"),
taskPlaneApprovalSigningKeyId: "owner-key-1",
taskPlaneApprovalRiskClass: "high",
taskPlaneResultRecipientBinding: OWNER_PRINCIPAL,
ownerProvenanceTtlMs: 60_000,
blockUnknownAgentRuns: true,
requireExplicitTrustedSenders: true,
requireSenderIsOwner: true,
trustedSenderIds: ["synthetic-owner"],
},
registerTool(factory, options) { factories[options.name] = factory; },
registerHttpRoute() {},
on(name, callback) { hooks[name] = callback; },
};
plugin.register(api);
return { api, hooks, factories, root };
}
async function ownerContext(value, runId = `run-${crypto.randomUUID()}`) {
const event = { prompt: "candidate acceptance", senderId: "synthetic-owner", senderIsOwner: true, runId };
const ctx = { runId, agentId: "portal-agent", sessionId: `session-${runId}`, channelId: "weixin" };
assert.deepEqual(await value.hooks.before_agent_run(event, ctx), { outcome: "pass" });
return ctx;
}
function taskPlan(taskId, overrides = {}) {
return {
task_id: taskId,
parent_session_id: "parent-session-candidate",
requested_role: "dedicated_platform_writer",
required_capabilities: [...CAPABILITIES],
repository: "aios/aios",
branch: BRANCH,
approved_base_oid: BASE,
allowed_paths: [ALLOWED_PATH],
credential_policy: { ttl_seconds: 1800, max_uses: 100 },
result_delivery: {
channel: "supervisor",
recipient_binding: OWNER_PRINCIPAL,
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
},
...overrides,
};
}
function taskPlaneParams(taskId, { planOverrides = {}, approvalOverrides = {}, ...overrides } = {}) {
const plan = taskPlan(taskId, planOverrides);
const now = Date.now();
const approval = {
approval_id: `approval-${taskId}`,
task_id: taskId,
decision: "approved",
policy_id: "token-policy-v2:candidate-session-broker",
owner_principal_id: OWNER_PRINCIPAL,
task_plan_sha256: taskPlanDigest(plan),
approved_capabilities: [...CAPABILITIES],
not_before: new Date(now - 1000).toISOString(),
expires_at: new Date(now + 60_000).toISOString(),
...approvalOverrides,
};
return {
task_id: taskId,
provider: "chatgpt-supervisor",
action: "candidate.session.provision",
payload: { task_plan: plan, approval_record: approval },
idempotency_key: `idem-${taskId}`,
...overrides,
};
}
async function authorizeAndExecute(value, params, suffix = crypto.randomUUID()) {
const ctx = await ownerContext(value, `run-${suffix}`);
const callId = `call-${suffix}`;
assert.equal(await value.hooks.before_tool_call({
toolName: "aios_protected_dispatch", toolCallId: callId, runId: ctx.runId, params,
}, ctx), undefined);
return value.factories.aios_protected_dispatch({}).execute(callId, params);
}
async function fakeBroker(t, handler) {
let calls = 0;
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
calls += 1;
try {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
const result = handler(body, req);
res.writeHead(result.status ?? 200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
});
});
await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject));
t.after(() => new Promise((resolve) => server.close(resolve)));
return {
endpoint: `http://127.0.0.1:${server.address().port}`,
calls: () => calls,
};
}
async function fakeOwnerSigner(t, root, { malformed = false } = {}) {
const socketPath = path.join(root, "owner-signer.sock");
const requests = [];
const server = net.createServer((socket) => {
let input = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => { input += chunk; });
socket.on("end", () => {
const request = JSON.parse(input.trim());
requests.push(request);
if (malformed) {
socket.end(`${JSON.stringify({ error: "signer_failed" })}\n`);
return;
}
socket.end(`${JSON.stringify({
approval_plan_id: request.approval_plan_id,
approval_key_id: request.signing_key_id,
approval_signature: "external-owner-signature",
approval_payload: request.approval_payload,
})}\n`);
});
});
await new Promise((resolve, reject) => server.listen(socketPath, resolve).once("error", reject));
t.after(() => new Promise((resolve) => server.close(resolve)));
return { socketPath, requests };
}
function provisionResponse(body, overrides = {}) {
return {
simulation: false,
status: "provisioned",
broker_session_verified: true,
openclaw_native_child_session_verified: false,
codex_child_session_verified: false,
child_session_id: "session-candidate-1",
worker_id: "worker-local-candidate-1",
task_id: body.task_id,
execution_endpoint_id: "endpoint-loopback-1",
credential_reference: "secret-ref://aios/session-candidate-1",
bound_branch: body.branch,
bound_base_oid: body.approved_base_oid,
worktree_id: "worktree-candidate-1",
expires_at: new Date(Date.now() + 60_000).toISOString(),
capabilities: body.required_capabilities,
...overrides,
};
}
test("mock remains simulation", async () => {
Object.values(__state).forEach((value) => value.clear());
const value = fixture({ mode: "mock", taskId: "TASK-MOCK" });
const params = {
task_id: "TASK-MOCK", provider: "chatgpt-supervisor", action: "status",
payload: {}, idempotency_key: "mock-1",
};
const result = await authorizeAndExecute(value, params, "mock");
assert.equal(result.details.simulation, true);
assert.equal(result.details.transport, "mock");
});
test("sandbox remains simulation", async () => {
Object.values(__state).forEach((value) => value.clear());
const value = fixture({ mode: "sandbox", taskId: "TASK-SANDBOX" });
const params = {
task_id: "TASK-SANDBOX", provider: "chatgpt-supervisor", action: "status",
payload: {}, idempotency_key: "sandbox-1",
};
const result = await authorizeAndExecute(value, params, "sandbox");
assert.equal(result.details.simulation, true);
assert.equal(result.details.transport, "sandbox");
});
test("task-plane provisions a real broker session with exact contract", async (t) => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-PLANE-SUCCESS";
const broker = await fakeBroker(t, (body, req) => {
assert.equal(req.url, "/v1/sessions/provision");
assert.match(String(req.headers.authorization), /^Bearer /);
assert.deepEqual(Object.keys(body).sort(), [
"allowed_paths", "approval_envelope", "approved_base_oid", "branch", "credential_policy",
"idempotency_key", "owner_provenance_id", "parent_session_id", "repository",
"requested_role", "required_capabilities", "result_delivery", "task_id",
].sort());
assert.deepEqual(Object.keys(body.approval_envelope).sort(), [
"approval_plan_id", "approval_key_id", "approval_signature", "approval_payload",
].sort());
assert.equal(body.approval_envelope.approval_key_id, "owner-key-1");
assert.equal(body.approval_envelope.approval_payload.task_plan_sha256, taskPlanDigest({
task_id: body.task_id,
parent_session_id: body.parent_session_id,
requested_role: body.requested_role,
required_capabilities: body.required_capabilities,
repository: body.repository,
branch: body.branch,
approved_base_oid: body.approved_base_oid,
allowed_paths: body.allowed_paths,
credential_policy: body.credential_policy,
result_delivery: body.result_delivery,
}));
return { body: provisionResponse(body) };
});
const value = fixture({ mode: "task-plane", taskId });
const signer = await fakeOwnerSigner(t, value.root);
value.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath = signer.socketPath;
value.api.pluginConfig.protectedDispatchBrokerEndpoint = broker.endpoint;
const result = await authorizeAndExecute(value, taskPlaneParams(taskId), "success");
assert.equal(result.details.simulation, false);
assert.equal(result.details.status, "provisioned");
assert.equal(result.details.bound_branch, BRANCH);
assert.equal(result.details.credential_reference, "secret-ref://aios/session-candidate-1");
assert.equal(result.details.broker_session_verified, true);
assert.equal(result.details.openclaw_native_child_session_verified, false);
assert.equal(result.details.codex_child_session_verified, false);
assert.equal(result.details.plain_text_result_delivery, true);
assert.equal(result.details.supervisor_text_result.content_type, "text/plain");
assert.equal(result.details.supervisor_text_result.encoding, "utf-8");
assert.equal(result.details.supervisor_text_result.image_only, false);
assert.equal(result.details.supervisor_text_result.task_id, taskId);
assert.match(result.details.supervisor_text_result.message_id, /^message-[0-9a-f]{32}$/);
assert.match(result.details.supervisor_text_result.receipt, /^[0-9a-f]{64}$/);
assert.equal(result.details.supervisor_text_result.secrets_included, false);
assert.match(result.details.receipt_sha256, /^[0-9a-f]{64}$/);
assert.equal(signer.requests.length, 1);
assert.equal(signer.requests[0].canonical_payload, JSON.stringify(signer.requests[0].approval_payload));
assert.equal(signer.requests[0].approval_payload.approval_plan_id, `approval-${taskId}`);
assert.equal(signer.requests[0].approval_payload.risk_class, "high");
assert.equal(signer.requests[0].approval_payload.signing_key_id, "owner-key-1");
assert.match(signer.requests[0].approval_payload.nonce, /^approval-nonce-v1:[0-9a-f]{64}$/);
});
test("task-plane broker unavailable fails closed", async (t) => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-BROKER-OFFLINE";
const value = fixture({ mode: "task-plane", taskId });
const signer = await fakeOwnerSigner(t, value.root);
value.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath = signer.socketPath;
value.api.pluginConfig.protectedDispatchBrokerEndpoint = "http://127.0.0.1:9";
await assert.rejects(authorizeAndExecute(value, taskPlaneParams(taskId), "offline"), /broker_/);
});
test("task-plane requires trusted approval signer and exact recipient binding", async () => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-APPROVAL-SIGNER";
const missingSigner = fixture({ mode: "task-plane", taskId });
missingSigner.api.pluginConfig.protectedDispatchBrokerEndpoint = "http://127.0.0.1:9";
missingSigner.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath =
path.join(missingSigner.root, "missing-owner-signer.sock");
await assert.rejects(
authorizeAndExecute(missingSigner, taskPlaneParams(taskId), "missing-signer"),
/BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE/,
);
Object.values(__state).forEach((value) => value.clear());
const wrongRecipient = fixture({ mode: "task-plane", taskId: `${taskId}-RECIPIENT` });
wrongRecipient.api.pluginConfig.protectedDispatchBrokerEndpoint = "http://127.0.0.1:9";
const params = taskPlaneParams(`${taskId}-RECIPIENT`, {
planOverrides: {
result_delivery: {
channel: "supervisor",
recipient_binding: `principal:sha256:${"0".repeat(64)}`,
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
},
},
});
params.payload.approval_record.task_plan_sha256 = taskPlanDigest(params.payload.task_plan);
await assert.rejects(
authorizeAndExecute(wrongRecipient, params, "wrong-recipient"),
/task_plan_result_delivery_rejected/,
);
});
test("task-plane rejects malformed external signer envelope", async (t) => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-APPROVAL-MALFORMED";
const value = fixture({ mode: "task-plane", taskId });
const signer = await fakeOwnerSigner(t, value.root, { malformed: true });
value.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath = signer.socketPath;
value.api.pluginConfig.protectedDispatchBrokerEndpoint = "http://127.0.0.1:9";
await assert.rejects(
authorizeAndExecute(value, taskPlaneParams(taskId), "malformed-signer"),
/BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE/,
);
});
test("task-plane rejects missing plan, main, short base, capability expansion, and expired approval", async (t) => {
for (const [name, mutate, pattern] of [
["main", (params) => { params.payload.task_plan.branch = "main"; }, /task_plan_branch_rejected/],
["base", (params) => { params.payload.task_plan.approved_base_oid = "123"; }, /task_plan_base_invalid/],
["capability", (params) => {
params.payload.task_plan.required_capabilities.push("main.write");
params.payload.approval_record.approved_capabilities.push("main.write");
}, /task_plan_capability_mismatch/],
["expired", (params) => {
params.payload.approval_record.expires_at = new Date(Date.now() - 1000).toISOString();
}, /task_plan_approval_expired/],
]) {
await t.test(name, async () => {
Object.values(__state).forEach((value) => value.clear());
const taskId = `TASK-REJECT-${name.toUpperCase()}`;
const value = fixture({ mode: "task-plane", taskId });
value.api.pluginConfig.protectedDispatchBrokerEndpoint = "http://127.0.0.1:9";
const params = taskPlaneParams(taskId);
mutate(params);
if (name !== "expired") {
params.payload.approval_record.task_plan_sha256 = taskPlanDigest(params.payload.task_plan);
}
await assert.rejects(authorizeAndExecute(value, params, name), pattern);
});
}
});
test("unknown transport and non-loopback broker endpoint are rejected", async () => {
Object.values(__state).forEach((value) => value.clear());
const value = fixture({ mode: "unknown", taskId: "TASK-UNKNOWN" });
const params = {
task_id: "TASK-UNKNOWN", provider: "chatgpt-supervisor", action: "status",
payload: {}, idempotency_key: "unknown-1",
};
await assert.rejects(authorizeAndExecute(value, params, "unknown"), /protected_dispatch_transport_not_authorized/);
assert.throws(() => assertLocalBrokerEndpoint("http://192.0.2.1:19931"), /broker_endpoint_host_invalid/);
assert.throws(() => assertLocalBrokerEndpoint("http://127.0.0.1:19931/health"), /broker_endpoint_path_invalid/);
});
test("secret-bearing broker response is rejected", async (t) => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-SECRET-RESPONSE";
const broker = await fakeBroker(t, (body) => ({
body: provisionResponse(body, { token: "must-never-return" }),
}));
const value = fixture({ mode: "task-plane", taskId });
const signer = await fakeOwnerSigner(t, value.root);
value.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath = signer.socketPath;
value.api.pluginConfig.protectedDispatchBrokerEndpoint = broker.endpoint;
await assert.rejects(authorizeAndExecute(value, taskPlaneParams(taskId), "secret"), /broker_response_contains_secret_field/);
});
test("duplicate protected dispatch is idempotent and calls broker once", async (t) => {
Object.values(__state).forEach((value) => value.clear());
const taskId = "TASK-IDEMPOTENT";
const broker = await fakeBroker(t, (body) => ({ body: provisionResponse(body) }));
const value = fixture({ mode: "task-plane", taskId });
const signer = await fakeOwnerSigner(t, value.root);
value.api.pluginConfig.taskPlaneOwnerApprovalSignerSocketPath = signer.socketPath;
value.api.pluginConfig.protectedDispatchBrokerEndpoint = broker.endpoint;
const params = taskPlaneParams(taskId, { idempotency_key: "same-operation" });
const first = await authorizeAndExecute(value, params, "idem-1");
const second = await authorizeAndExecute(value, params, "idem-2");
assert.equal(first.details.child_session_id, second.details.child_session_id);
assert.equal(second.details.deduplicated, true);
assert.equal(broker.calls(), 1);
});
@@ -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,20 @@
import crypto from "node:crypto";
import { canonicalize } from "../provenance.js";
export function dispatchMockTransport({ permit, params, attempts, mode }) {
const result = {
accepted: true,
task_id: permit.request.task_id,
provider: permit.request.provider,
provider_source: "server_config",
provider_hint_overridden: params.provider !== permit.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");
return result;
}
@@ -0,0 +1,5 @@
import { dispatchMockTransport } from "./mock.js";
export function dispatchSandboxTransport(context) {
return dispatchMockTransport({ ...context, mode: "sandbox" });
}
@@ -0,0 +1,172 @@
import crypto from "node:crypto";
import net from "node:net";
import {
buildApprovalPayload,
buildProvisionRequest,
validateProvisionResponse,
validateTaskPlaneRequest,
} from "../dispatch-contract.js";
import { callBrokerProvision } from "../session-broker-client.js";
import { dispatchError } from "../dispatch-errors.js";
import { canonicalize } from "../provenance.js";
const DEFAULT_SIGNER_SOCKET = "/run/aios-owner-approval-signer-candidate/signer.sock";
const MAX_SIGNER_RESPONSE_BYTES = 256 * 1024;
function exactObject(value, keys) {
return value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).sort().join(",") === [...keys].sort().join(",");
}
function callOwnerApprovalSigner({ socketPath, request, timeoutMs }) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ path: socketPath });
let settled = false;
let response = "";
const finish = (error, value) => {
if (settled) return;
settled = true;
socket.destroy();
if (error) reject(error);
else resolve(value);
};
socket.setEncoding("utf8");
socket.setTimeout(timeoutMs);
socket.on("connect", () => socket.end(`${canonicalize(request)}\n`));
socket.on("data", (chunk) => {
response += chunk;
if (Buffer.byteLength(response, "utf8") > MAX_SIGNER_RESPONSE_BYTES) {
finish(new Error("owner_signer_response_too_large"));
}
});
socket.on("timeout", () => finish(new Error("owner_signer_timeout")));
socket.on("error", (error) => finish(error));
socket.on("end", () => {
try {
const line = response.trim();
finish(null, JSON.parse(line));
} catch (error) {
finish(error);
}
});
});
}
async function buildApprovalEnvelope(validated, config, idempotencyKey) {
const socketPath = config.taskPlaneOwnerApprovalSignerSocketPath || DEFAULT_SIGNER_SOCKET;
const keyId = config.taskPlaneApprovalSigningKeyId;
const riskClass = config.taskPlaneApprovalRiskClass;
if (typeof socketPath !== "string" || !socketPath.startsWith("/") ||
typeof keyId !== "string" || !keyId ||
typeof riskClass !== "string" || !riskClass) {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
const payload = buildApprovalPayload(validated, {
idempotencyKey,
riskClass,
signingKeyId: keyId,
});
const signerRequest = {
schema_version: "aios.owner-approval-sign-request.v1",
operation: "sign_owner_approval",
approval_plan_id: validated.approval.approval_plan_id,
signing_key_id: keyId,
canonical_payload: canonicalize(payload),
approval_payload: payload,
};
let envelope;
try {
envelope = await callOwnerApprovalSigner({
socketPath,
request: signerRequest,
timeoutMs: config.protectedDispatchBrokerTimeoutMs || 5_000,
});
} catch {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
if (!exactObject(envelope, [
"approval_plan_id", "approval_key_id", "approval_signature", "approval_payload",
]) ||
envelope.approval_plan_id !== validated.approval.approval_plan_id ||
envelope.approval_key_id !== keyId ||
typeof envelope.approval_signature !== "string" ||
!envelope.approval_signature ||
canonicalize(envelope.approval_payload) !== signerRequest.canonical_payload) {
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
}
return envelope;
}
function buildSupervisorTextResult({ request, validated, checked }) {
const content = [
"AIOS Candidate Session Broker result",
`task_id=${request.task_id}`,
`broker_session_id=${checked.value.child_session_id}`,
"broker_session_verified=true",
"openclaw_native_child_session_verified=false",
"codex_child_session_verified=false",
].join("\n");
const messageSeed = canonicalize({
task_id: request.task_id,
idempotency_key: request.idempotency_key,
child_session_id: checked.value.child_session_id,
recipient_binding: validated.plan.result_delivery.recipient_binding,
});
const messageId = `message-${crypto.createHash("sha256").update(messageSeed).digest("hex").slice(0, 32)}`;
const result = {
content_type: "text/plain",
encoding: "utf-8",
image_only: false,
task_id: request.task_id,
message_id: messageId,
recipient_binding: validated.plan.result_delivery.recipient_binding,
content,
secrets_included: false,
};
result.receipt = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
return result;
}
export async function dispatchTaskPlaneTransport({ permit, request, params, config, attempts }) {
const validated = validateTaskPlaneRequest({ request, permit, config });
if (!validated.ok) throw dispatchError(validated.code);
if (typeof config.protectedDispatchBrokerEndpoint !== "string" ||
typeof config.protectedDispatchBrokerCredentialPath !== "string") {
throw dispatchError("broker_config_invalid");
}
const approvalEnvelope = await buildApprovalEnvelope(validated, config, request.idempotency_key);
const provisionRequest = buildProvisionRequest(validated, request.idempotency_key, approvalEnvelope);
let response;
try {
response = await callBrokerProvision({
endpoint: config.protectedDispatchBrokerEndpoint,
credentialPath: config.protectedDispatchBrokerCredentialPath,
payload: provisionRequest,
timeoutMs: config.protectedDispatchBrokerTimeoutMs,
});
} catch (error) {
throw dispatchError(error instanceof Error && error.code ? error.code : "broker_unreachable");
}
const checked = validateProvisionResponse(response, validated.plan);
if (!checked.ok) throw dispatchError(checked.code);
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: false,
transport: "task-plane",
real_user_impersonated: false,
attempts,
...checked.value,
};
result.supervisor_text_result = buildSupervisorTextResult({ request, validated, checked });
result.plain_text_result_delivery = true;
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
return result;
}
+143
View File
@@ -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,35 @@
[Unit]
Description=AIOS Candidate Owner Approval Signer
After=local-fs.target
[Service]
Type=simple
User=aios-owner-signer-candidate
Group=aios-owner-signer-candidate
SupplementaryGroups=aios-owner-approval-caller-candidate
RuntimeDirectory=aios-owner-approval-signer-candidate
RuntimeDirectoryMode=0750
StateDirectory=aios-owner-approval-signer-candidate
StateDirectoryMode=0700
UMask=0007
ExecStart=/usr/bin/python3 /usr/local/libexec/aios-candidate-owner-approval-signer --socket /run/aios-owner-approval-signer-candidate/signer.sock --private-key /run/aios-owner-approval-signer-candidate/private/owner-approval.pem --key-id owner-candidate-session-broker-20260725 --approval-store /etc/aios-gitops/owner-approval-candidate/approval-store.json --revocations /etc/aios-gitops/owner-approval-candidate/revocations.json --nonce-ledger /var/lib/aios-owner-approval-signer-candidate/nonces.sqlite3 --caller-user aios-owner-approval-caller-candidate --caller-group aios-owner-approval-caller-candidate --caller-service-unit aios-owner-approval-caller-candidate.service
Restart=on-failure
RestartSec=1
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ReadOnlyPaths=/etc/aios-gitops/owner-approval-candidate
ReadWritePaths=/run/aios-owner-approval-signer-candidate /var/lib/aios-owner-approval-signer-candidate
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""Prepare or revoke runtime-only Token Policy V2 projections without printing secrets."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import pathlib
import pwd
import re
import secrets
import socket
import stat
import subprocess
import time
import urllib.error
import urllib.request
from typing import Any
TASK_ID = "AIOS-SESSION-BROKER-BOOTSTRAP-20260725"
BRANCH = "patch/openclaw-real-dispatch-session-broker-20260725"
BASE_OID = "95b3728abde86aebda60666f97bcd0576d87307a"
REPOSITORY = "aios/aios"
REPO = pathlib.Path("/data/aios-gitops/repo.git")
WORKTREE = pathlib.Path("/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725")
TASK_ROOT = pathlib.Path("/data/aios-gitops/runtime/tasks") / TASK_ID
SECRET_ROOT = TASK_ROOT / "secrets"
WRITER_RUNTIME = pathlib.Path("/run/aios-session-broker-bootstrap-writer-candidate")
BROKER_RUNTIME = pathlib.Path("/run/aios-openclaw-session-broker-candidate")
ALLOWED_PATH_ROOTS = [
str(WORKTREE),
str(TASK_ROOT),
str(WRITER_RUNTIME),
]
OPERATIONS = [
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
]
DENIED = [
"main.write",
"force_push",
"tag.write",
"baseline.write",
"registry.write",
"production.deploy",
"production.rollback",
"secret.export",
]
APPROVAL_ID = "approval-aios-session-broker-bootstrap-20260725"
GITEA_API_BASE = "http://127.0.0.1:3000/api/v1"
GITEA_KEY_TITLE = "aios-session-broker-bootstrap-candidate-20260725"
GITEA_KEY_PATH = SECRET_ROOT / "gitea-deploy-ed25519"
GITEA_KNOWN_HOSTS = pathlib.Path("/etc/aios-gitops/trust/gitea-ssh-known-hosts")
GITEA_KEY_RECORD = TASK_ROOT / "evidence/gitea-deploy-key.json"
GITEA_KEY_DELETE_RE = re.compile(r"^/repos/aios/aios/keys/\d+$")
GITEA_API_CREDENTIAL = "gitea_api_credential"
GITEA_HOST = "g.cnnetmkt.top"
GITEA_HOST_KEY_FINGERPRINT = "SHA256:MEKitmo00KZSQvDbmgjGpEtx+HlEDm0O74ZA1EV2Z78"
OWNER_APPROVAL_KEY_ID = "openclaw-v2r1-candidate-ed25519"
OWNER_APPROVAL_PUBLIC_KEY = pathlib.Path("/etc/openclaw/aios-v2-runtime-public-key.pem")
OWNER_APPROVAL_REVOCATIONS = pathlib.Path(
"/etc/aios-gitops/trust/owner-approval-revocations.json"
)
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def atomic_write(path: pathlib.Path, content: str, mode: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
try:
os.write(descriptor, content.encode("utf-8"))
os.fsync(descriptor)
finally:
os.close(descriptor)
os.replace(temporary, path)
os.chmod(path, mode)
def sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _load_gitea_api_credential() -> str:
directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
if not directory or not os.path.isabs(directory):
raise SystemExit("BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED")
path = pathlib.Path(directory) / GITEA_API_CREDENTIAL
try:
entry = os.lstat(path)
if not stat.S_ISREG(entry.st_mode) or stat.S_ISLNK(entry.st_mode) or entry.st_mode & 0o022:
raise OSError
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
except OSError as error:
raise SystemExit("BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED") from error
if len(lines) == 1:
return lines[0]
if len(lines) == 2 and lines[0] and lines[1]:
return lines[1]
raise SystemExit("BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED")
def _assert_gitea_api_call(method: str, path: str) -> None:
if method == "GET" and path == "/repos/aios/aios/keys":
return
if method == "POST" and path == "/repos/aios/aios/keys":
return
if method == "DELETE" and GITEA_KEY_DELETE_RE.fullmatch(path):
return
raise SystemExit("BLOCKED_GITEA_API_CALL_NOT_ALLOWED")
def _assert_gitea_known_hosts() -> None:
try:
entry = os.lstat(GITEA_KNOWN_HOSTS)
permissions = stat.S_IMODE(entry.st_mode)
if (
not stat.S_ISREG(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_uid != 0
or permissions & 0o133
or not permissions & 0o400
):
raise OSError
result = subprocess.run(
["/usr/bin/ssh-keygen", "-lf", str(GITEA_KNOWN_HOSTS)],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
if result.returncode != 0:
raise OSError
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(lines) != 1:
raise OSError
fields = lines[0].split()
if len(fields) < 2 or fields[1] != GITEA_HOST_KEY_FINGERPRINT:
raise OSError
except OSError as error:
raise SystemExit("BLOCKED_GITEA_SSH_HOST_KEY_MISMATCH") from error
def gitea_api(token: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
_assert_gitea_api_call(method, path)
data = canonical(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(
f"{GITEA_API_BASE}{path}",
data=data,
method=method,
headers={
"Authorization": f"token {token}",
"Host": GITEA_HOST,
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read()
return response.status, json.loads(raw) if raw else None
except urllib.error.HTTPError as error:
raw = error.read()
try:
value = json.loads(raw) if raw else None
except json.JSONDecodeError:
value = None
return error.code, value
def git(*arguments: str) -> str:
result = subprocess.run(
["/usr/bin/git", "-c", f"safe.directory={WORKTREE}", *arguments],
cwd=WORKTREE,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
return result.stdout.strip()
def assert_port_free(port: int) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
except OSError as error:
code = "BLOCKED_CANDIDATE_PORT_IN_USE" if port == 19932 else "BLOCKED_CANDIDATE_BROKER_PORT_IN_USE"
raise SystemExit(f"{code}\nport={port}") from error
def common_policy(*, audience: str, token: str, now: int) -> dict[str, Any]:
return {
"schema_version": "aios.token-policy.v2",
"task_id": TASK_ID,
"writer_instance_id": "writer-session-broker-bootstrap-candidate-20260725",
"audience": audience,
"source_cidr": "127.0.0.1",
"repository": REPOSITORY,
"repository_source": str(REPO),
"exact_branch": BRANCH,
"approved_base_oid": BASE_OID,
"exact_operations": OPERATIONS,
"denied_operations": DENIED,
"allowed_path_roots": ALLOWED_PATH_ROOTS,
"not_before": now - 5,
"expires_at": now + 1800,
"max_uses": 100,
"token_sha256": sha256(token),
}
def prepare() -> None:
if not REPO.is_dir() or not WORKTREE.is_dir():
raise SystemExit("BLOCKED_CANDIDATE_WORKTREE_MISSING")
source = subprocess.run(
["/usr/bin/git", "-C", str(REPO), "rev-parse", f"{BASE_OID}^{{commit}}"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
).stdout.strip()
if source != BASE_OID:
raise SystemExit(f"BLOCKED_SOURCE_OID_MISMATCH\nexpected={BASE_OID}\nactual={source}")
if git("symbolic-ref", "--short", "HEAD") != BRANCH:
raise SystemExit("BLOCKED_CANDIDATE_BRANCH_MISMATCH")
current = git("rev-parse", "HEAD")
if len(current) != 40:
raise SystemExit("BLOCKED_CANDIDATE_HEAD_INVALID")
assert_port_free(19932)
assert_port_free(19931)
TASK_ROOT.mkdir(parents=True, exist_ok=True, mode=0o770)
for child in ["reports", "evidence", "artifacts"]:
(TASK_ROOT / child).mkdir(parents=True, exist_ok=True, mode=0o770)
SECRET_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
writer_token = secrets.token_urlsafe(48)
broker_token = secrets.token_urlsafe(48)
now = int(time.time())
writer_policy = {
**common_policy(
audience="aios-session-broker-bootstrap-writer-candidate",
token=writer_token,
now=now,
),
"push_remote": "gitea-http",
"push_url": "https://g.cnnetmkt.top/git/aios/aios.git",
"command_profiles": {
"node-plugin-tests": {
"operation": "test.execute",
"argv": ["/usr/bin/npm", "test", "--prefix", str(WORKTREE / "plugins/aios-tenant-boundary")],
"timeout_seconds": 180,
},
"python-candidate-tests": {
"operation": "test.execute",
"argv": [
"/usr/bin/python3",
"-m",
"unittest",
"discover",
"-s",
str(WORKTREE / "services/aios-session-broker-candidate/tests"),
],
"timeout_seconds": 180,
},
"candidate-compile": {
"operation": "artifact.build",
"argv": [
"/usr/bin/python3",
"-m",
"compileall",
"-q",
str(WORKTREE / "services/aios-session-broker-candidate"),
],
"timeout_seconds": 120,
},
},
}
broker_policy = {
**common_policy(
audience="aios-openclaw-session-broker-candidate",
token=broker_token,
now=now,
),
"approval_id": APPROVAL_ID,
"risk_class": "high",
"approval_verifier": {
"key_id": OWNER_APPROVAL_KEY_ID,
"public_key_path": str(OWNER_APPROVAL_PUBLIC_KEY),
"revocations_path": str(OWNER_APPROVAL_REVOCATIONS),
},
"lease_seconds": 10,
}
atomic_write(SECRET_ROOT / "writer-token.source", f"{writer_token}\n", 0o600)
atomic_write(SECRET_ROOT / "broker-control-token.source", f"{broker_token}\n", 0o600)
atomic_write(TASK_ROOT / "writer-policy.json", f"{canonical(writer_policy)}\n", 0o640)
atomic_write(TASK_ROOT / "broker-policy.json", f"{canonical(broker_policy)}\n", 0o640)
print(
canonical(
{
"status": "prepared",
"task_id": TASK_ID,
"branch": BRANCH,
"branch_oid": current,
"writer_port": 19932,
"broker_port": 19931,
"credential_references_created": True,
"secrets_included": False,
}
)
)
def revoke() -> None:
now = int(time.time()) - 1
for name in ["writer-policy.json", "broker-policy.json"]:
path = TASK_ROOT / name
if not path.exists():
continue
value = json.loads(path.read_text(encoding="utf-8"))
value["expires_at"] = now
atomic_write(path, f"{canonical(value)}\n", 0o640)
print(canonical({"status": "revoked", "task_id": TASK_ID, "secrets_included": False}))
def _assert_key_record(record: dict[str, Any]) -> int:
try:
key_id = int(record["key_id"])
except (TypeError, ValueError, KeyError) as exc:
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_RECORD_MALFORMED") from exc
if key_id < 1:
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_RECORD_MALFORMED")
return key_id
def provision_gitea_key() -> None:
if os.geteuid() != 0:
raise SystemExit("BLOCKED_GITEA_KEY_ADMIN_REQUIRED")
SECRET_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
account = pwd.getpwnam("aiman")
if not GITEA_KEY_PATH.exists():
subprocess.run(
[
"/usr/bin/ssh-keygen", "-q", "-t", "ed25519", "-N", "",
"-C", GITEA_KEY_TITLE, "-f", str(GITEA_KEY_PATH),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_assert_gitea_known_hosts()
api_token = _load_gitea_api_credential()
for path, mode in [
(GITEA_KEY_PATH, 0o600),
(GITEA_KEY_PATH.with_suffix(".pub"), 0o640),
]:
os.chmod(path, mode)
os.chown(path, account.pw_uid, account.pw_gid)
public_key = GITEA_KEY_PATH.with_suffix(".pub").read_text(encoding="utf-8").strip()
status, keys = gitea_api(api_token, "GET", "/repos/aios/aios/keys")
if status != 200 or not isinstance(keys, list):
raise SystemExit(f"BLOCKED_GITEA_DEPLOY_KEY_LIST_HTTP_{status}")
matching = [item for item in keys if isinstance(item, dict) and item.get("title") == GITEA_KEY_TITLE]
if matching:
key = matching[0]
if key.get("key", "").strip() != public_key:
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_TITLE_CONFLICT")
key_id = _assert_key_record(key)
else:
status, created = gitea_api(
api_token,
"POST",
"/repos/aios/aios/keys",
{"title": GITEA_KEY_TITLE, "key": public_key, "read_only": False},
)
if status != 201 or not isinstance(created, dict):
raise SystemExit(f"BLOCKED_GITEA_DEPLOY_KEY_CREATE_HTTP_{status}")
key_id = _assert_key_record(created)
record = {
"schema_version": "aios.candidate-gitea-deploy-key/v1",
"task_id": TASK_ID,
"repository": REPOSITORY,
"title": GITEA_KEY_TITLE,
"key_id": key_id,
"read_only": False,
"created_or_reused_at": int(time.time()),
"revoked": False,
"secret_plaintext_disclosed": False,
}
atomic_write(GITEA_KEY_RECORD, f"{canonical(record)}\n", 0o640)
os.chown(GITEA_KEY_RECORD, account.pw_uid, account.pw_gid)
print(
canonical(
{
"status": "gitea_deploy_key_ready",
"task_id": TASK_ID,
"repository": REPOSITORY,
"key_id": key_id,
"read_only": False,
"secrets_included": False,
}
)
)
def revoke_gitea_key() -> None:
if os.geteuid() != 0:
raise SystemExit("BLOCKED_GITEA_KEY_ADMIN_REQUIRED")
if not GITEA_KEY_RECORD.exists():
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_RECORD_MISSING")
record = json.loads(GITEA_KEY_RECORD.read_text(encoding="utf-8"))
key_id = _assert_key_record(record)
_assert_gitea_known_hosts()
api_token = _load_gitea_api_credential()
status, _ = gitea_api(api_token, "DELETE", f"/repos/aios/aios/keys/{key_id}")
if status not in {204, 404}:
raise SystemExit(f"BLOCKED_GITEA_DEPLOY_KEY_REVOKE_HTTP_{status}")
status, keys = gitea_api(api_token, "GET", "/repos/aios/aios/keys")
if status != 200:
raise SystemExit(f"BLOCKED_GITEA_DEPLOY_KEY_LIST_HTTP_{status}")
for item in keys:
if not isinstance(item, dict):
continue
try:
listed_key_id = int(item.get("id"))
except (TypeError, ValueError):
continue
if listed_key_id == key_id:
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_REVOKE_VERIFY_FAILED")
ssh_command = (
f"/usr/bin/ssh -i {GITEA_KEY_PATH} -o IdentitiesOnly=yes "
f"-o UserKnownHostsFile={GITEA_KNOWN_HOSTS} "
"-o BatchMode=yes -o StrictHostKeyChecking=yes"
)
probe = subprocess.run(
[
"/usr/bin/git",
"ls-remote",
f"aiman@{GITEA_HOST}:aios/aios.git",
f"refs/heads/{BRANCH}",
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
timeout=15,
env={
**os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_SSH_COMMAND": ssh_command,
},
)
if probe.returncode == 0:
raise SystemExit("BLOCKED_GITEA_DEPLOY_KEY_REVOKE_FAILED_TO_PROBE")
for path in [GITEA_KEY_PATH, GITEA_KEY_PATH.with_suffix(".pub")]:
path.unlink(missing_ok=True)
record["revoked"] = True
record["revoked_at"] = int(time.time())
atomic_write(GITEA_KEY_RECORD, f"{canonical(record)}\n", 0o640)
print(
canonical(
{
"status": "gitea_deploy_key_revoked",
"task_id": TASK_ID,
"key_id": key_id,
"secrets_included": False,
}
)
)
def cleanup_sources() -> None:
for path in [
SECRET_ROOT / "writer-token.source",
SECRET_ROOT / "broker-control-token.source",
GITEA_KEY_PATH,
GITEA_KEY_PATH.with_suffix(".pub"),
]:
path.unlink(missing_ok=True)
print(canonical({"status": "token_sources_removed", "task_id": TASK_ID, "secrets_included": False}))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"action",
choices=[
"prepare", "revoke", "provision-gitea-key",
"revoke-gitea-key", "cleanup-sources",
],
)
action = parser.parse_args().action
{
"prepare": prepare,
"revoke": revoke,
"provision-gitea-key": provision_gitea_key,
"revoke-gitea-key": revoke_gitea_key,
"cleanup-sources": cleanup_sources,
}[action]()
if __name__ == "__main__":
main()
@@ -0,0 +1,166 @@
"""Shared fail-closed helpers for the isolated AIOS candidate services."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import pathlib
import tempfile
import time
from typing import Any
MAX_BODY_BYTES = 1024 * 1024
OID_LENGTH = 40
LOOPBACKS = {"127.0.0.1", "::1"}
class CandidateError(Exception):
def __init__(self, code: str, status: int = 400):
super().__init__(code)
self.code = code
self.status = status
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def now_epoch() -> int:
return int(time.time())
def parse_epoch(value: Any, field: str) -> int:
if isinstance(value, bool):
raise CandidateError(f"{field}_invalid")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise CandidateError(f"{field}_invalid") from exc
if parsed <= 0:
raise CandidateError(f"{field}_invalid")
return parsed
def is_oid(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == OID_LENGTH
and all(character in "0123456789abcdef" for character in value)
)
def load_json(path: pathlib.Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CandidateError("policy_unreadable", 503) from exc
if not isinstance(value, dict):
raise CandidateError("policy_invalid", 503)
return value
def atomic_json(path: pathlib.Path, value: Any, mode: int = 0o600) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary_path = pathlib.Path(temporary)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
handle.write(f"{canonical(value)}\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_path, mode)
os.replace(temporary_path, path)
directory_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
temporary_path.unlink(missing_ok=True)
def read_credential(name: str) -> str:
directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
if not directory or "/" in name or "\\" in name:
raise CandidateError("credential_reference_unavailable", 503)
path = pathlib.Path(directory) / name
try:
stat = path.lstat()
if not path.is_file() or path.is_symlink() or stat.st_mode & 0o007:
raise CandidateError("credential_projection_invalid", 503)
value = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise CandidateError("credential_reference_unavailable", 503) from exc
if not value:
raise CandidateError("credential_reference_unavailable", 503)
return value
def authorize_bearer(headers: Any, expected: str) -> None:
supplied = str(headers.get("Authorization", ""))
prefix = "Bearer "
if not supplied.startswith(prefix) or not hmac.compare_digest(supplied[len(prefix) :], expected):
raise CandidateError("unauthorized", 401)
def require_loopback(address: str) -> None:
if address not in LOOPBACKS:
raise CandidateError("source_cidr_rejected", 403)
def read_request_json(handler: Any) -> dict[str, Any]:
raw_length = handler.headers.get("Content-Length", "")
try:
length = int(raw_length)
except ValueError as exc:
raise CandidateError("content_length_invalid", 400) from exc
if length < 0 or length > MAX_BODY_BYTES:
raise CandidateError("request_too_large", 413)
try:
value = json.loads(handler.rfile.read(length).decode("utf-8") or "{}")
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CandidateError("invalid_json", 400) from exc
if not isinstance(value, dict):
raise CandidateError("invalid_json_object", 400)
return value
def send_json(handler: Any, status: int, value: Any) -> None:
body = canonical(value).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.send_header("X-Content-Type-Options", "nosniff")
handler.end_headers()
handler.wfile.write(body)
def public_error(error: Exception) -> tuple[int, dict[str, str]]:
if isinstance(error, CandidateError):
return error.status, {"error": error.code}
return 500, {"error": "internal_error"}
def validate_grant_window(policy: dict[str, Any], expected_audience: str) -> None:
if policy.get("schema_version") != "aios.token-policy.v2":
raise CandidateError("token_policy_version_rejected", 503)
if policy.get("audience") != expected_audience:
raise CandidateError("token_policy_audience_rejected", 503)
if policy.get("source_cidr") != "127.0.0.1":
raise CandidateError("token_policy_source_rejected", 503)
now = now_epoch()
if now < parse_epoch(policy.get("not_before"), "not_before"):
raise CandidateError("credential_not_yet_valid", 401)
if now >= parse_epoch(policy.get("expires_at"), "expires_at"):
raise CandidateError("credential_expired", 401)
max_uses = policy.get("max_uses")
if not isinstance(max_uses, int) or isinstance(max_uses, bool) or not 1 <= max_uses <= 1000:
raise CandidateError("token_policy_max_uses_invalid", 503)
@@ -0,0 +1,597 @@
#!/usr/bin/env python3
"""Candidate-only Owner approval signer isolated behind an authenticated Unix socket."""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import grp
import json
import os
import pathlib
import pwd
import re
import socket
import socketserver
import sqlite3
import stat
import struct
import subprocess
import tempfile
import time
from typing import Any
from candidate_common import CandidateError, canonical
REQUEST_KEYS = {
"schema_version",
"operation",
"approval_plan_id",
"signing_key_id",
"canonical_payload",
"approval_payload",
}
PAYLOAD_KEYS = {
"approval_plan_id",
"task_id",
"task_plan_sha256",
"repository",
"branch",
"approved_base_oid",
"allowed_paths",
"approved_capabilities",
"risk_class",
"not_before",
"expires_at",
"nonce",
"signing_key_id",
}
APPROVAL_STORE_KEYS = {"schema_version", "records"}
APPROVAL_RECORD_KEYS = {
"schema_version",
"decision",
"approval_plan_id",
"signing_key_id",
"authorized_caller",
"approval_payload",
}
AUTHORIZED_CALLER_KEYS = {"uid", "gid", "service_unit"}
REVOCATIONS_KEYS = {"schema_version", "revoked_plan_ids", "revoked_key_ids"}
MAX_REQUEST_BYTES = 256 * 1024
MAX_TRUST_FILE_BYTES = 1024 * 1024
SERVICE_UNIT_PATTERN = re.compile(r"[A-Za-z0-9_.@:-]+\.service")
def validate_private_key(path: pathlib.Path) -> None:
try:
entry = os.lstat(path)
except OSError as exc:
raise CandidateError("owner_signer_private_key_unavailable", 503) from exc
mode = stat.S_IMODE(entry.st_mode)
if (
not path.is_absolute()
or not stat.S_ISREG(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_uid != os.geteuid()
or mode & 0o077
or not mode & 0o400
):
raise CandidateError("owner_signer_private_key_unavailable", 503)
def validate_request(value: Any, key_id: str) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != REQUEST_KEYS:
raise CandidateError("owner_signer_request_invalid", 403)
if value.get("schema_version") != "aios.owner-approval-sign-request.v1":
raise CandidateError("owner_signer_request_invalid", 403)
if value.get("operation") != "sign_owner_approval":
raise CandidateError("owner_signer_operation_rejected", 403)
payload = value.get("approval_payload")
if not isinstance(payload, dict) or set(payload) != PAYLOAD_KEYS:
raise CandidateError("owner_signer_payload_invalid", 403)
if value.get("approval_plan_id") != payload.get("approval_plan_id"):
raise CandidateError("owner_signer_plan_binding_rejected", 403)
if value.get("signing_key_id") != key_id or payload.get("signing_key_id") != key_id:
raise CandidateError("owner_signer_key_binding_rejected", 403)
canonical_payload = canonical(payload)
if value.get("canonical_payload") != canonical_payload:
raise CandidateError("owner_signer_canonical_payload_rejected", 403)
if payload.get("risk_class") != "high":
raise CandidateError("owner_signer_risk_class_rejected", 403)
if not isinstance(payload.get("task_plan_sha256"), str) or not re.fullmatch(
r"[0-9a-f]{64}", payload["task_plan_sha256"]
):
raise CandidateError("owner_signer_task_plan_digest_invalid", 403)
if not isinstance(payload.get("approved_base_oid"), str) or not re.fullmatch(
r"[0-9a-f]{40}", payload["approved_base_oid"]
):
raise CandidateError("owner_signer_base_oid_invalid", 403)
if not isinstance(payload.get("nonce"), str) or not re.fullmatch(
r"approval-nonce-v1:[0-9a-f]{64}", payload["nonce"]
):
raise CandidateError("owner_signer_nonce_invalid", 403)
for field in ("approval_plan_id", "task_id", "repository", "branch", "signing_key_id"):
if not isinstance(payload.get(field), str) or not payload[field]:
raise CandidateError(f"owner_signer_{field}_invalid", 403)
for field in ("allowed_paths", "approved_capabilities"):
values = payload.get(field)
if (
not isinstance(values, list)
or not values
or any(not isinstance(item, str) or not item for item in values)
or len(values) != len(set(values))
):
raise CandidateError(f"owner_signer_{field}_invalid", 403)
return payload
def parse_approval_time(value: Any, field: str) -> dt.datetime:
if not isinstance(value, str) or not value.endswith("Z"):
raise CandidateError(f"owner_signer_{field}_invalid", 403)
try:
parsed = dt.datetime.fromisoformat(f"{value[:-1]}+00:00")
except ValueError as exc:
raise CandidateError(f"owner_signer_{field}_invalid", 403) from exc
if parsed.tzinfo is None:
raise CandidateError(f"owner_signer_{field}_invalid", 403)
return parsed.astimezone(dt.timezone.utc)
def secure_read_root_json(path: pathlib.Path) -> Any:
if not path.is_absolute():
raise CandidateError("owner_signer_trust_file_invalid", 503)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise CandidateError("owner_signer_trust_file_unavailable", 503) from exc
try:
entry = os.fstat(descriptor)
if (
not stat.S_ISREG(entry.st_mode)
or entry.st_uid != 0
or stat.S_IMODE(entry.st_mode) & 0o022
):
raise CandidateError("owner_signer_trust_file_invalid", 503)
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
descriptor = -1
raw = handle.read(MAX_TRUST_FILE_BYTES + 1)
if len(raw.encode("utf-8")) > MAX_TRUST_FILE_BYTES:
raise CandidateError("owner_signer_trust_file_invalid", 503)
return json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CandidateError("owner_signer_trust_file_invalid", 503) from exc
finally:
if descriptor >= 0:
os.close(descriptor)
def validate_authorized_caller(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != AUTHORIZED_CALLER_KEYS:
raise CandidateError("owner_signer_approval_record_invalid", 503)
if (
not isinstance(value.get("uid"), int)
or isinstance(value.get("uid"), bool)
or value["uid"] <= 0
or not isinstance(value.get("gid"), int)
or isinstance(value.get("gid"), bool)
or value["gid"] <= 0
or not isinstance(value.get("service_unit"), str)
or SERVICE_UNIT_PATTERN.fullmatch(value["service_unit"]) is None
):
raise CandidateError("owner_signer_approval_record_invalid", 503)
return value
def approval_record_for(store: Any, approval_plan_id: str) -> dict[str, Any]:
if not isinstance(store, dict) or set(store) != APPROVAL_STORE_KEYS:
raise CandidateError("owner_signer_approval_store_invalid", 503)
if store.get("schema_version") != "aios.owner-approval-store.v1":
raise CandidateError("owner_signer_approval_store_invalid", 503)
records = store.get("records")
if not isinstance(records, list) or not records:
raise CandidateError("owner_signer_approval_store_invalid", 503)
selected: dict[str, Any] | None = None
seen: set[str] = set()
for record in records:
if not isinstance(record, dict) or set(record) != APPROVAL_RECORD_KEYS:
raise CandidateError("owner_signer_approval_record_invalid", 503)
if record.get("schema_version") != "aios.owner-approval-record.v1":
raise CandidateError("owner_signer_approval_record_invalid", 503)
if record.get("decision") not in {"approved", "denied"}:
raise CandidateError("owner_signer_approval_record_invalid", 503)
plan_id = record.get("approval_plan_id")
key_id = record.get("signing_key_id")
payload = record.get("approval_payload")
if (
not isinstance(plan_id, str)
or not plan_id
or plan_id in seen
or not isinstance(key_id, str)
or not key_id
or not isinstance(payload, dict)
or set(payload) != PAYLOAD_KEYS
or payload.get("approval_plan_id") != plan_id
or payload.get("signing_key_id") != key_id
):
raise CandidateError("owner_signer_approval_record_invalid", 503)
validate_authorized_caller(record.get("authorized_caller"))
validate_request(
{
"schema_version": "aios.owner-approval-sign-request.v1",
"operation": "sign_owner_approval",
"approval_plan_id": plan_id,
"signing_key_id": key_id,
"canonical_payload": canonical(payload),
"approval_payload": payload,
},
key_id,
)
seen.add(plan_id)
if plan_id == approval_plan_id:
selected = record
if selected is None:
raise CandidateError("owner_signer_approval_unknown", 403)
return selected
def validate_revocations(value: Any) -> dict[str, list[str]]:
if not isinstance(value, dict) or set(value) != REVOCATIONS_KEYS:
raise CandidateError("owner_signer_revocations_invalid", 503)
if value.get("schema_version") != "aios.owner-approval-revocations.v1":
raise CandidateError("owner_signer_revocations_invalid", 503)
for field in ("revoked_plan_ids", "revoked_key_ids"):
entries = value.get(field)
if (
not isinstance(entries, list)
or any(not isinstance(item, str) or not item for item in entries)
or len(entries) != len(set(entries))
):
raise CandidateError("owner_signer_revocations_invalid", 503)
return value
def validate_peer_identity(
peer: dict[str, Any],
*,
expected_uid: int,
expected_gid: int,
expected_service_unit: str,
) -> None:
if peer.get("uid") != expected_uid:
raise CandidateError("owner_signer_peer_uid_rejected", 403)
if peer.get("gid") != expected_gid:
raise CandidateError("owner_signer_peer_gid_rejected", 403)
if peer.get("service_unit") != expected_service_unit:
raise CandidateError("owner_signer_peer_service_rejected", 403)
if not isinstance(peer.get("pid"), int) or peer["pid"] <= 0:
raise CandidateError("owner_signer_peer_pid_rejected", 403)
def proc_starttime(pid: int) -> str:
try:
raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError as exc:
raise CandidateError("owner_signer_peer_pid_rejected", 403) from exc
closing = raw.rfind(")")
fields = raw[closing + 2 :].split() if closing >= 0 else []
if len(fields) < 20:
raise CandidateError("owner_signer_peer_pid_rejected", 403)
return fields[19]
def proc_service_unit(pid: int) -> str:
try:
lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise CandidateError("owner_signer_peer_service_rejected", 403) from exc
units = {
component
for line in lines
for component in line.split(":", 2)[-1].split("/")
if SERVICE_UNIT_PATTERN.fullmatch(component)
}
if len(units) != 1:
raise CandidateError("owner_signer_peer_service_rejected", 403)
return units.pop()
def authenticated_peer(
connection: socket.socket,
*,
expected_uid: int,
expected_gid: int,
expected_service_unit: str,
) -> dict[str, Any]:
if not hasattr(socket, "SO_PEERCRED"):
raise CandidateError("owner_signer_peer_credentials_unavailable", 503)
try:
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", raw)
except (OSError, struct.error) as exc:
raise CandidateError("owner_signer_peer_credentials_unavailable", 503) from exc
before = proc_starttime(pid)
service_unit = proc_service_unit(pid)
after = proc_starttime(pid)
if before != after:
raise CandidateError("owner_signer_peer_pid_reused", 403)
peer = {"pid": pid, "uid": uid, "gid": gid, "service_unit": service_unit}
validate_peer_identity(
peer,
expected_uid=expected_uid,
expected_gid=expected_gid,
expected_service_unit=expected_service_unit,
)
return peer
def authorize_approval(
payload: dict[str, Any],
*,
key_id: str,
peer: dict[str, Any],
approval_store: Any,
revocations: Any,
now: dt.datetime | None = None,
) -> dict[str, Any]:
record = approval_record_for(approval_store, payload["approval_plan_id"])
revoked = validate_revocations(revocations)
if record["decision"] != "approved":
raise CandidateError("owner_signer_approval_denied", 403)
if (
record["approval_plan_id"] in revoked["revoked_plan_ids"]
or key_id in revoked["revoked_key_ids"]
):
raise CandidateError("owner_signer_approval_revoked", 403)
if record["signing_key_id"] != key_id:
raise CandidateError("owner_signer_approval_key_rejected", 403)
caller = validate_authorized_caller(record["authorized_caller"])
if (
caller["uid"] != peer.get("uid")
or caller["gid"] != peer.get("gid")
or caller["service_unit"] != peer.get("service_unit")
):
raise CandidateError("owner_signer_approval_caller_rejected", 403)
if canonical(record["approval_payload"]) != canonical(payload):
raise CandidateError("owner_signer_approval_payload_rejected", 403)
not_before = parse_approval_time(payload.get("not_before"), "not_before")
expires_at = parse_approval_time(payload.get("expires_at"), "expires_at")
current = now or dt.datetime.now(dt.timezone.utc)
if expires_at <= not_before:
raise CandidateError("owner_signer_approval_window_rejected", 403)
if current < not_before:
raise CandidateError("owner_signer_approval_not_yet_valid", 403)
if current >= expires_at:
raise CandidateError("owner_signer_approval_expired", 403)
return record
def validate_nonce_ledger(path: pathlib.Path) -> None:
if not path.is_absolute():
raise CandidateError("owner_signer_nonce_ledger_invalid", 503)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
parent = os.lstat(path.parent)
if (
not stat.S_ISDIR(parent.st_mode)
or stat.S_ISLNK(parent.st_mode)
or parent.st_uid != os.geteuid()
or stat.S_IMODE(parent.st_mode) & 0o077
):
raise CandidateError("owner_signer_nonce_ledger_invalid", 503)
if path.exists():
entry = os.lstat(path)
if (
not stat.S_ISREG(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_uid != os.geteuid()
or stat.S_IMODE(entry.st_mode) & 0o077
):
raise CandidateError("owner_signer_nonce_ledger_invalid", 503)
connection = sqlite3.connect(path, timeout=5)
try:
connection.execute("PRAGMA trusted_schema=OFF")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS consumed_nonces (
nonce TEXT PRIMARY KEY,
approval_plan_id TEXT NOT NULL,
task_id TEXT NOT NULL,
consumed_at INTEGER NOT NULL
)
"""
)
connection.commit()
finally:
connection.close()
os.chmod(path, 0o600)
def consume_nonce(path: pathlib.Path, payload: dict[str, Any]) -> None:
connection = sqlite3.connect(path, timeout=5, isolation_level=None)
try:
connection.execute("PRAGMA trusted_schema=OFF")
connection.execute("BEGIN IMMEDIATE")
try:
connection.execute(
"INSERT INTO consumed_nonces(nonce, approval_plan_id, task_id, consumed_at) VALUES (?, ?, ?, ?)",
(
payload["nonce"],
payload["approval_plan_id"],
payload["task_id"],
int(time.time()),
),
)
except sqlite3.IntegrityError as exc:
connection.rollback()
raise CandidateError("owner_signer_nonce_replay", 403) from exc
connection.commit()
except CandidateError:
raise
except sqlite3.Error as exc:
connection.rollback()
raise CandidateError("owner_signer_nonce_ledger_unavailable", 503) from exc
finally:
connection.close()
def sign_payload(payload: dict[str, Any], private_key: pathlib.Path) -> str:
validate_private_key(private_key)
payload_fd, payload_name = tempfile.mkstemp(prefix="owner-approval-payload-", suffix=".json")
signature_fd, signature_name = tempfile.mkstemp(prefix="owner-approval-signature-")
try:
os.write(payload_fd, canonical(payload).encode("utf-8"))
os.fsync(payload_fd)
os.close(payload_fd)
payload_fd = -1
os.close(signature_fd)
signature_fd = -1
result = subprocess.run(
[
"/usr/bin/openssl",
"pkeyutl",
"-sign",
"-inkey",
str(private_key),
"-rawin",
"-in",
payload_name,
"-out",
signature_name,
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=4,
)
if result.returncode != 0:
raise CandidateError("owner_signer_signing_failed", 503)
return base64.urlsafe_b64encode(pathlib.Path(signature_name).read_bytes()).decode("ascii").rstrip("=")
finally:
if payload_fd >= 0:
os.close(payload_fd)
if signature_fd >= 0:
os.close(signature_fd)
pathlib.Path(payload_name).unlink(missing_ok=True)
pathlib.Path(signature_name).unlink(missing_ok=True)
class SignerHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
try:
self.request.settimeout(5)
peer = authenticated_peer(
self.request,
expected_uid=self.server.caller_uid, # type: ignore[attr-defined]
expected_gid=self.server.caller_gid, # type: ignore[attr-defined]
expected_service_unit=self.server.caller_service_unit, # type: ignore[attr-defined]
)
raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
if not raw or len(raw) > MAX_REQUEST_BYTES or self.rfile.read(1):
raise CandidateError("owner_signer_request_invalid", 403)
request = json.loads(raw.decode("utf-8"))
payload = validate_request(request, self.server.key_id) # type: ignore[attr-defined]
authorize_approval(
payload,
key_id=self.server.key_id, # type: ignore[attr-defined]
peer=peer,
approval_store=secure_read_root_json(self.server.approval_store), # type: ignore[attr-defined]
revocations=secure_read_root_json(self.server.revocations), # type: ignore[attr-defined]
)
consume_nonce(self.server.nonce_ledger, payload) # type: ignore[attr-defined]
signature = sign_payload(payload, self.server.private_key) # type: ignore[attr-defined]
response = {
"approval_plan_id": request["approval_plan_id"],
"approval_key_id": self.server.key_id, # type: ignore[attr-defined]
"approval_signature": signature,
"approval_payload": payload,
}
except Exception:
response = {
"error": "BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE",
"secrets_included": False,
}
self.wfile.write(f"{canonical(response)}\n".encode("utf-8"))
class SignerServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
allow_reuse_address = False
daemon_threads = True
def prepare_socket_parent(path: pathlib.Path, caller_gid: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
entry = os.lstat(path.parent)
if (
not stat.S_ISDIR(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_uid != os.geteuid()
or stat.S_IMODE(entry.st_mode) & 0o007
):
raise CandidateError("owner_signer_socket_directory_invalid", 503)
os.chown(path.parent, -1, caller_gid)
os.chmod(path.parent, 0o750)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", type=pathlib.Path, required=True)
parser.add_argument("--private-key", type=pathlib.Path, required=True)
parser.add_argument("--key-id", required=True)
parser.add_argument("--approval-store", type=pathlib.Path, required=True)
parser.add_argument("--revocations", type=pathlib.Path, required=True)
parser.add_argument("--nonce-ledger", type=pathlib.Path, required=True)
parser.add_argument("--caller-user", required=True)
parser.add_argument("--caller-group", required=True)
parser.add_argument("--caller-service-unit", required=True)
arguments = parser.parse_args()
paths = (
arguments.socket,
arguments.private_key,
arguments.approval_store,
arguments.revocations,
arguments.nonce_ledger,
)
if (
any(not path.is_absolute() for path in paths)
or not arguments.key_id
or arguments.caller_group == "gitops"
or SERVICE_UNIT_PATTERN.fullmatch(arguments.caller_service_unit) is None
):
raise CandidateError("owner_signer_configuration_invalid", 503)
try:
caller_user = pwd.getpwnam(arguments.caller_user)
caller_group = grp.getgrnam(arguments.caller_group)
except KeyError as exc:
raise CandidateError("owner_signer_caller_identity_unavailable", 503) from exc
if caller_user.pw_gid != caller_group.gr_gid or os.getegid() == caller_group.gr_gid:
raise CandidateError("owner_signer_caller_identity_invalid", 503)
validate_private_key(arguments.private_key)
approval_store = secure_read_root_json(arguments.approval_store)
revocations = secure_read_root_json(arguments.revocations)
if not isinstance(approval_store, dict) or set(approval_store) != APPROVAL_STORE_KEYS:
raise CandidateError("owner_signer_approval_store_invalid", 503)
validate_revocations(revocations)
validate_nonce_ledger(arguments.nonce_ledger)
prepare_socket_parent(arguments.socket, caller_group.gr_gid)
arguments.socket.unlink(missing_ok=True)
with SignerServer(str(arguments.socket), SignerHandler) as server:
server.private_key = arguments.private_key # type: ignore[attr-defined]
server.key_id = arguments.key_id # type: ignore[attr-defined]
server.approval_store = arguments.approval_store # type: ignore[attr-defined]
server.revocations = arguments.revocations # type: ignore[attr-defined]
server.nonce_ledger = arguments.nonce_ledger # type: ignore[attr-defined]
server.caller_uid = caller_user.pw_uid # type: ignore[attr-defined]
server.caller_gid = caller_group.gr_gid # type: ignore[attr-defined]
server.caller_service_unit = arguments.caller_service_unit # type: ignore[attr-defined]
os.chown(arguments.socket, -1, caller_group.gr_gid)
os.chmod(arguments.socket, 0o660)
try:
server.serve_forever()
finally:
arguments.socket.unlink(missing_ok=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,805 @@
#!/usr/bin/env python3
"""Durable loopback Candidate Session Broker with revocable projected credentials."""
from __future__ import annotations
import argparse
import base64
import binascii
import hashlib
import hmac
import json
import os
import pathlib
import re
import secrets
import sqlite3
import stat
import threading
import subprocess
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from candidate_common import (
CandidateError,
authorize_bearer,
canonical,
is_oid,
load_json,
now_epoch,
public_error,
read_credential,
read_request_json,
require_loopback,
send_json,
sha256_text,
validate_grant_window,
)
SESSION_RE = re.compile(r"^/v1/sessions/(session-[a-f0-9]{32})(?:/(heartbeat|close|interrupt))?$")
SECRET_KEYS = re.compile(
r"^(?:token|access_token|authorization|authorization_header|credential|credential_value|secret|secret_value)$",
re.IGNORECASE,
)
FORBIDDEN_OPERATIONS = {
"main.write",
"force_push",
"tag.write",
"baseline.write",
"registry.write",
"production.deploy",
"production.rollback",
"secret.export",
}
EXPECTED_CAPABILITIES = {
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
}
APPROVAL_ENVELOPE_KEYS = {
"approval_plan_id",
"approval_key_id",
"approval_signature",
"approval_payload",
}
APPROVAL_PAYLOAD_KEYS = {
"approval_plan_id",
"task_id",
"task_plan_sha256",
"repository",
"branch",
"approved_base_oid",
"allowed_paths",
"approved_capabilities",
"risk_class",
"not_before",
"expires_at",
"nonce",
"signing_key_id",
}
RESULT_DELIVERY_KEYS = {
"channel",
"recipient_binding",
"content_type",
"encoding",
"image_only",
}
RECIPIENT_BINDING_RE = re.compile(r"^principal:sha256:[0-9a-f]{64}$")
def exact_string_set(value: Any, code: str) -> set[str]:
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
raise CandidateError(code)
values = set(value)
if len(values) != len(value):
raise CandidateError(code)
return values
def contains_secret_field(value: Any) -> bool:
if isinstance(value, list):
return any(contains_secret_field(item) for item in value)
if not isinstance(value, dict):
return False
return any(SECRET_KEYS.fullmatch(str(key)) or contains_secret_field(item) for key, item in value.items())
def parse_iso_timestamp(value: Any, field: str) -> int:
if not isinstance(value, str) or not value.strip():
raise CandidateError(f"{field}_invalid")
text = value
if value.endswith("Z"):
text = f"{value[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise CandidateError(f"{field}_invalid") from exc
if parsed.tzinfo is None:
raise CandidateError(f"{field}_invalid")
return int(parsed.timestamp())
class CandidateSessionBroker:
def __init__(
self,
*,
policy_path: pathlib.Path,
database_path: pathlib.Path,
projection_root: pathlib.Path,
writer_endpoint: str,
) -> None:
self.policy_path = policy_path
self.policy = load_json(policy_path)
validate_grant_window(self.policy, "aios-openclaw-session-broker-candidate")
self.control_token = read_credential("broker_control_token")
if self.policy.get("token_sha256") != sha256_text(self.control_token):
raise CandidateError("credential_policy_binding_failed", 503)
if writer_endpoint != "http://127.0.0.1:19932":
raise CandidateError("writer_endpoint_rejected", 503)
self.writer_endpoint = writer_endpoint
self.database_path = database_path
self.projection_root = projection_root
self.projection_root.mkdir(parents=True, exist_ok=True, mode=0o700)
self.lock = threading.RLock()
self._validate_policy()
self.approval_verifier = self._load_approval_verifier()
self.database_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.connection = sqlite3.connect(self.database_path, check_same_thread=False)
self.connection.row_factory = sqlite3.Row
self._initialize_database()
self._expire_sessions()
def _validate_policy(self) -> None:
for field in [
"task_id", "writer_instance_id", "repository", "exact_branch",
"approved_base_oid", "approval_id",
]:
if not isinstance(self.policy.get(field), str) or not self.policy[field]:
raise CandidateError(f"token_policy_{field}_invalid", 503)
if self.policy["exact_branch"].lower() in {"main", "master", "trunk"}:
raise CandidateError("token_policy_branch_rejected", 503)
if not is_oid(self.policy["approved_base_oid"]):
raise CandidateError("token_policy_base_oid_invalid", 503)
if self.policy.get("risk_class") != "high":
raise CandidateError("token_policy_risk_class_invalid", 503)
capabilities = exact_string_set(self.policy.get("exact_operations"), "token_policy_operations_invalid")
if capabilities != EXPECTED_CAPABILITIES:
raise CandidateError("token_policy_operations_invalid", 503)
denied = exact_string_set(self.policy.get("denied_operations"), "token_policy_denials_invalid")
if denied != FORBIDDEN_OPERATIONS:
raise CandidateError("token_policy_denials_invalid", 503)
self.allowed_paths = exact_string_set(
self.policy.get("allowed_path_roots"), "token_policy_path_roots_invalid"
)
lease = self.policy.get("lease_seconds", 30)
if not isinstance(lease, int) or isinstance(lease, bool) or not 3 <= lease <= 300:
raise CandidateError("token_policy_lease_invalid", 503)
self.lease_seconds = lease
def _load_approval_verifier(self) -> dict[str, str]:
verifier = self.policy.get("approval_verifier")
if not isinstance(verifier, dict) or set(verifier) != {"key_id", "public_key_path", "revocations_path"}:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
key_id = verifier.get("key_id")
public_key_path = verifier.get("public_key_path")
revocations_path = verifier.get("revocations_path")
if not isinstance(key_id, str) or not key_id:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
if not isinstance(public_key_path, str) or not pathlib.Path(public_key_path).is_absolute():
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
if not isinstance(revocations_path, str) or not pathlib.Path(revocations_path).is_absolute():
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
try:
public_key_stat = os.lstat(public_key_path)
permissions = stat.S_IMODE(public_key_stat.st_mode)
if (
not stat.S_ISREG(public_key_stat.st_mode)
or stat.S_ISLNK(public_key_stat.st_mode)
or public_key_stat.st_uid != 0
or permissions & 0o133
or not permissions & 0o400
):
raise OSError
except OSError as exc:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503) from exc
return {
"key_id": key_id,
"public_key_path": public_key_path,
"revocations_path": revocations_path,
}
def _load_revocations(self) -> list[Any]:
path = pathlib.Path(self.approval_verifier["revocations_path"])
try:
entry = os.lstat(path)
permissions = stat.S_IMODE(entry.st_mode)
if (
not stat.S_ISREG(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_uid != 0
or permissions & 0o133
or not permissions & 0o400
):
raise OSError
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503) from exc
if isinstance(value, dict) and isinstance(value.get("revocations"), list):
return value["revocations"]
if isinstance(value, list):
return value
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
def _is_revoked(self, approval_plan_id: str, approval_key_id: str) -> bool:
revocations = self._load_revocations()
for item in revocations:
if isinstance(item, str):
if item == approval_plan_id or item == approval_key_id:
return True
if isinstance(item, dict):
if item.get("approval_plan_id") == approval_plan_id or item.get("approval_key_id") == approval_key_id:
return True
return False
def _verify_approval_signature(
self,
*,
approval_key_id: str,
approval_signature: str,
approval_payload: dict[str, Any],
) -> None:
if approval_key_id != self.approval_verifier["key_id"]:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503)
if not isinstance(approval_signature, str) or not approval_signature or re.search(r"\s", approval_signature):
raise CandidateError("approval_signature_invalid", 403)
padding = "=" * (-len(approval_signature) % 4)
try:
signature = base64.b64decode(
f"{approval_signature}{padding}",
altchars=b"-_",
validate=True,
)
except (binascii.Error, ValueError) as exc:
raise CandidateError("approval_signature_invalid", 403) from exc
public_key_path = pathlib.Path(self.approval_verifier["public_key_path"])
try:
public_key_stat = os.lstat(public_key_path)
permissions = stat.S_IMODE(public_key_stat.st_mode)
if (
not stat.S_ISREG(public_key_stat.st_mode)
or stat.S_ISLNK(public_key_stat.st_mode)
or public_key_stat.st_uid != 0
or permissions & 0o133
or not permissions & 0o400
):
raise OSError
except OSError as exc:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503) from exc
payload = canonical(approval_payload).encode("utf-8")
fd_payload, payload_path = tempfile.mkstemp(prefix="approval-payload-", suffix=".json")
fd_signature, signature_path = tempfile.mkstemp(prefix="approval-signature-")
try:
os.write(fd_payload, payload)
os.write(fd_signature, signature)
os.fsync(fd_payload)
os.fsync(fd_signature)
check = subprocess.run(
[
"/usr/bin/openssl",
"pkeyutl",
"-verify",
"-pubin",
"-rawin",
"-inkey",
str(public_key_path),
"-in",
payload_path,
"-sigfile",
signature_path,
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
timeout=4,
)
except OSError as exc:
raise CandidateError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE", 503) from exc
finally:
os.close(fd_payload)
os.close(fd_signature)
pathlib.Path(payload_path).unlink(missing_ok=True)
pathlib.Path(signature_path).unlink(missing_ok=True)
if check.returncode != 0:
raise CandidateError("approval_signature_invalid", 403)
def _initialize_database(self) -> None:
with self.connection:
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
)
"""
)
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL,
request_sha256 TEXT NOT NULL,
response_json TEXT NOT NULL,
status TEXT NOT NULL,
worker_id TEXT NOT NULL,
credential_sha256 TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_heartbeat INTEGER NOT NULL,
uses INTEGER NOT NULL,
max_uses INTEGER NOT NULL,
revoked INTEGER NOT NULL,
interrupted INTEGER NOT NULL
)
"""
)
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS approval_nonces (
nonce TEXT PRIMARY KEY,
approval_plan_id TEXT NOT NULL,
approval_key_id TEXT NOT NULL,
request_sha256 TEXT NOT NULL,
consumed_at INTEGER NOT NULL
)
"""
)
self.connection.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('control_uses', 0)")
self.connection.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('worker_sequence', 0)")
def _meta_increment(self, key: str) -> int:
self.connection.execute("UPDATE meta SET value = value + 1 WHERE key = ?", (key,))
row = self.connection.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
if row is None:
raise CandidateError("broker_state_invalid", 503)
return int(row["value"])
def _control_auth(self, client: str, headers: Any) -> None:
require_loopback(client)
current_policy = load_json(self.policy_path)
validate_grant_window(current_policy, "aios-openclaw-session-broker-candidate")
if current_policy.get("token_sha256") != sha256_text(self.control_token):
raise CandidateError("credential_policy_binding_failed", 401)
authorize_bearer(headers, self.control_token)
with self.connection:
uses = self._meta_increment("control_uses")
if uses > current_policy["max_uses"]:
raise CandidateError("credential_max_uses_exhausted", 401)
def _projection_path(self, session_id: str) -> pathlib.Path:
return self.projection_root / f"{session_id}.token"
def _project_token(self, session_id: str, token: str) -> None:
path = self._projection_path(session_id)
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
os.write(descriptor, f"{token}\n".encode("utf-8"))
os.fsync(descriptor)
finally:
os.close(descriptor)
def _revoke_projection(self, session_id: str) -> None:
self._projection_path(session_id).unlink(missing_ok=True)
def writer_reachable(self) -> bool:
try:
with urllib.request.urlopen(f"{self.writer_endpoint}/health", timeout=1.5) as response:
body = json.loads(response.read().decode("utf-8"))
return (
response.status == 200
and body.get("status") == "healthy"
and body.get("service") == "aios-session-broker-bootstrap-writer-candidate"
and body.get("branch") == self.policy["exact_branch"]
)
except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError):
return False
def _expire_sessions(self) -> None:
now = now_epoch()
with self.lock, self.connection:
rows = self.connection.execute(
"""
SELECT session_id FROM sessions
WHERE revoked = 0 AND (
expires_at <= ? OR
(status = 'provisioned' AND last_heartbeat + ? <= ?)
)
""",
(now, self.lease_seconds, now),
).fetchall()
for row in rows:
self.connection.execute(
"UPDATE sessions SET status = 'lease_expired', revoked = 1 WHERE session_id = ?",
(row["session_id"],),
)
self._revoke_projection(row["session_id"])
def _validate_provision(self, request: dict[str, Any]) -> dict[str, Any]:
expected_keys = {
"task_id", "parent_session_id", "requested_role", "required_capabilities",
"repository", "branch", "approved_base_oid", "allowed_paths",
"credential_policy", "result_delivery", "idempotency_key",
"approval_envelope", "owner_provenance_id",
}
if set(request) != expected_keys or contains_secret_field(request):
raise CandidateError("provision_schema_rejected")
for field in ["task_id", "parent_session_id", "repository", "branch", "idempotency_key", "owner_provenance_id"]:
if not isinstance(request.get(field), str) or not request[field]:
raise CandidateError(f"{field}_invalid")
if request["task_id"] != self.policy["task_id"]:
raise CandidateError("task_id_mismatch", 403)
if request["requested_role"] != "dedicated_platform_writer":
raise CandidateError("requested_role_rejected", 403)
if request["repository"] != self.policy["repository"]:
raise CandidateError("repository_mismatch", 403)
if request["branch"] != self.policy["exact_branch"] or request["branch"].lower() in {"main", "master", "trunk"}:
raise CandidateError("branch_rejected", 403)
if request["approved_base_oid"] != self.policy["approved_base_oid"] or not is_oid(request["approved_base_oid"]):
raise CandidateError("approved_base_oid_rejected", 403)
if exact_string_set(request["required_capabilities"], "capabilities_invalid") != EXPECTED_CAPABILITIES:
raise CandidateError("capability_expansion_rejected", 403)
if exact_string_set(request["allowed_paths"], "allowed_paths_invalid") != self.allowed_paths:
raise CandidateError("allowed_paths_rejected", 403)
credential_policy = request.get("credential_policy")
if not isinstance(credential_policy, dict) or set(credential_policy) != {"ttl_seconds", "max_uses"}:
raise CandidateError("credential_policy_invalid")
ttl = credential_policy.get("ttl_seconds")
max_uses = credential_policy.get("max_uses")
if not isinstance(ttl, int) or isinstance(ttl, bool) or not 60 <= ttl <= 1800:
raise CandidateError("credential_ttl_rejected", 403)
if not isinstance(max_uses, int) or isinstance(max_uses, bool) or not 1 <= max_uses <= 100:
raise CandidateError("credential_max_uses_rejected", 403)
result_delivery = request.get("result_delivery")
if not isinstance(result_delivery, dict) or set(result_delivery) != RESULT_DELIVERY_KEYS:
raise CandidateError("result_delivery_invalid", 403)
if (
result_delivery.get("channel") != "supervisor"
or result_delivery.get("content_type") != "text/plain"
or result_delivery.get("encoding") != "utf-8"
or result_delivery.get("image_only") is not False
or not isinstance(result_delivery.get("recipient_binding"), str)
or not RECIPIENT_BINDING_RE.fullmatch(result_delivery["recipient_binding"])
):
raise CandidateError("result_delivery_invalid", 403)
approval_envelope = request["approval_envelope"]
if not isinstance(approval_envelope, dict) or set(approval_envelope) != APPROVAL_ENVELOPE_KEYS:
raise CandidateError("approval_envelope_invalid", 403)
approval_plan_id = approval_envelope["approval_plan_id"]
approval_key_id = approval_envelope["approval_key_id"]
approval_signature = approval_envelope["approval_signature"]
approval_payload = approval_envelope["approval_payload"]
if not isinstance(approval_plan_id, str) or not approval_plan_id:
raise CandidateError("approval_plan_id_invalid", 403)
if approval_plan_id != self.policy["approval_id"]:
raise CandidateError("approval_plan_id_rejected", 403)
if not isinstance(approval_key_id, str) or not approval_key_id:
raise CandidateError("approval_key_id_invalid", 403)
if not isinstance(approval_signature, str) or not approval_signature:
raise CandidateError("approval_signature_invalid", 403)
if not isinstance(approval_payload, dict) or set(approval_payload) != APPROVAL_PAYLOAD_KEYS:
raise CandidateError("approval_payload_invalid", 403)
if approval_payload.get("approval_plan_id") != approval_plan_id:
raise CandidateError("approval_payload_plan_id_rejected", 403)
if approval_payload.get("signing_key_id") != approval_key_id:
raise CandidateError("approval_payload_signing_key_id_rejected", 403)
if approval_payload.get("risk_class") != self.policy["risk_class"]:
raise CandidateError("approval_payload_risk_class_rejected", 403)
nonce = approval_payload.get("nonce")
if not isinstance(nonce, str) or not re.fullmatch(r"approval-nonce-v1:[0-9a-f]{64}", nonce):
raise CandidateError("approval_payload_nonce_invalid", 403)
if not isinstance(approval_payload.get("task_plan_sha256"), str) or not re.fullmatch(
r"[0-9a-f]{64}", approval_payload["task_plan_sha256"]
):
raise CandidateError("approval_payload_task_plan_sha256_invalid", 403)
if approval_payload["task_id"] != self.policy["task_id"]:
raise CandidateError("approval_payload_task_id_rejected", 403)
if approval_payload["repository"] != self.policy["repository"]:
raise CandidateError("approval_payload_repository_rejected", 403)
if approval_payload["branch"] != self.policy["exact_branch"]:
raise CandidateError("approval_payload_branch_rejected", 403)
if not is_oid(approval_payload["approved_base_oid"]) or approval_payload["approved_base_oid"] != self.policy["approved_base_oid"]:
raise CandidateError("approval_payload_base_oid_rejected", 403)
if exact_string_set(
approval_payload["approved_capabilities"], "approval_payload_capabilities_invalid"
) != EXPECTED_CAPABILITIES:
raise CandidateError("approval_payload_capabilities_rejected", 403)
if exact_string_set(
approval_payload["allowed_paths"], "approval_payload_allowed_paths_invalid"
) != self.allowed_paths:
raise CandidateError("approval_payload_allowed_paths_rejected", 403)
task_plan = {
"task_id": request["task_id"],
"parent_session_id": request["parent_session_id"],
"requested_role": request["requested_role"],
"required_capabilities": request["required_capabilities"],
"repository": request["repository"],
"branch": request["branch"],
"approved_base_oid": request["approved_base_oid"],
"allowed_paths": request["allowed_paths"],
"credential_policy": request["credential_policy"],
"result_delivery": request["result_delivery"],
}
if approval_payload["task_plan_sha256"] != sha256_text(canonical(task_plan)):
raise CandidateError("approval_payload_task_plan_digest_rejected", 403)
not_before = parse_iso_timestamp(approval_payload["not_before"], "approval_payload_not_before")
expires_at = parse_iso_timestamp(approval_payload["expires_at"], "approval_payload_expires_at")
if not_before >= expires_at or (expires_at - not_before) > 1800:
raise CandidateError("approval_payload_time_window_invalid", 403)
now = now_epoch()
if now < not_before or now >= expires_at:
raise CandidateError("approval_payload_not_current", 403)
if self._is_revoked(approval_plan_id, approval_key_id):
raise CandidateError("approval_payload_revoked", 403)
self._verify_approval_signature(
approval_key_id=approval_key_id,
approval_signature=approval_signature,
approval_payload=approval_payload,
)
return {
"ttl_seconds": ttl,
"max_uses": max_uses,
"approval_plan_id": approval_plan_id,
"approval_key_id": approval_key_id,
"nonce": nonce,
}
def provision(self, client: str, headers: Any, request: dict[str, Any]) -> tuple[int, dict[str, Any]]:
self._control_auth(client, headers)
self._expire_sessions()
credential_policy = self._validate_provision(request)
digest = sha256_text(canonical(request))
with self.lock, self.connection:
prior = self.connection.execute(
"SELECT request_sha256, response_json, status FROM sessions WHERE idempotency_key = ?",
(request["idempotency_key"],),
).fetchone()
if prior:
if prior["request_sha256"] != digest:
raise CandidateError("idempotency_conflict", 409)
response = json.loads(prior["response_json"])
response["deduplicated"] = True
response["status"] = prior["status"]
return 200, response
if not self.writer_reachable():
raise CandidateError("candidate_writer_unreachable", 503)
try:
self.connection.execute(
"""
INSERT INTO approval_nonces(
nonce, approval_plan_id, approval_key_id, request_sha256, consumed_at
) VALUES (?, ?, ?, ?, ?)
""",
(
credential_policy["nonce"],
credential_policy["approval_plan_id"],
credential_policy["approval_key_id"],
digest,
now_epoch(),
),
)
except sqlite3.IntegrityError as exc:
raise CandidateError("approval_nonce_replay", 409) from exc
session_id = f"session-{secrets.token_hex(16)}"
worker_sequence = self._meta_increment("worker_sequence")
worker_id = f"worker-local-candidate-test-{worker_sequence}"
token = secrets.token_urlsafe(48)
self._project_token(session_id, token)
now = now_epoch()
expires = now + credential_policy["ttl_seconds"]
response = {
"simulation": False,
"status": "provisioned",
"broker_session_verified": True,
"child_session_id": session_id,
"worker_id": worker_id,
"task_id": request["task_id"],
"execution_endpoint_id": f"endpoint-{session_id.removeprefix('session-')}",
"credential_reference": f"secret-ref://aios/session-broker-candidate/{session_id}",
"bound_branch": request["branch"],
"bound_base_oid": request["approved_base_oid"],
"worktree_id": "worktree-openclaw-real-dispatch-session-broker-20260725",
"expires_at": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z"),
"capabilities": sorted(EXPECTED_CAPABILITIES),
"worker_type": "local_candidate_test_worker",
"codex_authenticated": False,
"production_agent": False,
"openclaw_native_session": False,
"openclaw_native_child_session_verified": False,
"codex_child_session_verified": False,
}
self.connection.execute(
"""
INSERT INTO sessions(
session_id, idempotency_key, request_sha256, response_json, status,
worker_id, credential_sha256, created_at, expires_at, last_heartbeat,
uses, max_uses, revoked, interrupted
) VALUES (?, ?, ?, ?, 'provisioned', ?, ?, ?, ?, ?, 0, ?, 0, 0)
""",
(
session_id,
request["idempotency_key"],
digest,
canonical(response),
worker_id,
sha256_text(token),
now,
expires,
now,
credential_policy["max_uses"],
),
)
return 201, response
def _session(self, session_id: str) -> sqlite3.Row:
self._expire_sessions()
row = self.connection.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)).fetchone()
if row is None:
raise CandidateError("session_not_found", 404)
return row
def public_session(self, client: str, headers: Any, session_id: str) -> dict[str, Any]:
self._control_auth(client, headers)
with self.lock:
row = self._session(session_id)
response = json.loads(row["response_json"])
response["status"] = row["status"]
response["credential_revoked"] = bool(row["revoked"])
response["last_heartbeat"] = row["last_heartbeat"]
return response
def heartbeat(self, client: str, headers: Any, session_id: str) -> dict[str, Any]:
require_loopback(client)
with self.lock, self.connection:
row = self._session(session_id)
if row["revoked"] or row["status"] != "provisioned" or row["expires_at"] <= now_epoch():
raise CandidateError("session_credential_revoked", 401)
supplied = str(headers.get("Authorization", ""))
if not supplied.startswith("Bearer "):
raise CandidateError("unauthorized", 401)
token = supplied[len("Bearer ") :]
if not hmac.compare_digest(hashlib.sha256(token.encode("utf-8")).hexdigest(), row["credential_sha256"]):
raise CandidateError("unauthorized", 401)
uses = int(row["uses"]) + 1
if uses > int(row["max_uses"]):
self.connection.execute(
"UPDATE sessions SET revoked = 1, status = 'credential_exhausted' WHERE session_id = ?",
(session_id,),
)
self._revoke_projection(session_id)
raise CandidateError("credential_max_uses_exhausted", 401)
now = now_epoch()
self.connection.execute(
"UPDATE sessions SET uses = ?, last_heartbeat = ? WHERE session_id = ?",
(uses, now, session_id),
)
return {"status": "active", "session_id": session_id, "heartbeat_at": now, "credential_uses": uses}
def close(self, client: str, headers: Any, session_id: str, *, interrupted: bool = False) -> dict[str, Any]:
self._control_auth(client, headers)
with self.lock, self.connection:
self._session(session_id)
status = "interrupted" if interrupted else "closed"
self.connection.execute(
"UPDATE sessions SET status = ?, revoked = 1, interrupted = ? WHERE session_id = ?",
(status, int(interrupted), session_id),
)
self._revoke_projection(session_id)
return {
"status": status,
"session_id": session_id,
"credential_revoked": True,
"secrets_included": False,
}
def health(self) -> dict[str, Any]:
self._expire_sessions()
return {
"status": "healthy",
"service": "aios-openclaw-session-broker-candidate",
"bind": "loopback",
"candidate_writer_reachable": self.writer_reachable(),
"worker_type": "local_candidate_test_worker",
"codex_authenticated": False,
"production_agent": False,
"broker_session_verified": True,
"openclaw_native_child_session_verified": False,
"codex_child_session_verified": False,
"secrets_included": False,
}
class Handler(BaseHTTPRequestHandler):
server_version = "AIOSCandidateSessionBroker/1"
def log_message(self, _format: str, *_arguments: Any) -> None:
return
@property
def broker(self) -> CandidateSessionBroker:
return self.server.broker # type: ignore[attr-defined]
def do_GET(self) -> None: # noqa: N802
try:
require_loopback(self.client_address[0])
if self.path == "/health":
send_json(self, 200, self.broker.health())
return
match = SESSION_RE.fullmatch(self.path)
if not match or match.group(2):
raise CandidateError("not_found", 404)
send_json(self, 200, self.broker.public_session(self.client_address[0], self.headers, match.group(1)))
except Exception as error: # noqa: BLE001
status, body = public_error(error)
send_json(self, status, body)
def do_POST(self) -> None: # noqa: N802
try:
if self.path == "/v1/sessions/provision":
request = read_request_json(self)
status, body = self.broker.provision(self.client_address[0], self.headers, request)
send_json(self, status, body)
return
match = SESSION_RE.fullmatch(self.path)
if not match or not match.group(2):
raise CandidateError("not_found", 404)
operation = match.group(2)
if operation == "heartbeat":
read_request_json(self)
body = self.broker.heartbeat(self.client_address[0], self.headers, match.group(1))
elif operation == "close":
read_request_json(self)
body = self.broker.close(self.client_address[0], self.headers, match.group(1))
else:
read_request_json(self)
body = self.broker.close(
self.client_address[0], self.headers, match.group(1), interrupted=True
)
send_json(self, 200, body)
except Exception as error: # noqa: BLE001
status, body = public_error(error)
send_json(self, status, body)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--policy", type=pathlib.Path, required=True)
parser.add_argument("--database", type=pathlib.Path, required=True)
parser.add_argument("--projection-root", type=pathlib.Path, required=True)
parser.add_argument("--writer-endpoint", default="http://127.0.0.1:19932")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=19931)
arguments = parser.parse_args()
if arguments.host != "127.0.0.1" or arguments.port != 19931:
raise CandidateError("candidate_broker_bind_rejected", 503)
broker = CandidateSessionBroker(
policy_path=arguments.policy,
database_path=arguments.database,
projection_root=arguments.projection_root,
writer_endpoint=arguments.writer_endpoint,
)
server = ThreadingHTTPServer((arguments.host, arguments.port), Handler)
server.broker = broker # type: ignore[attr-defined]
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,518 @@
#!/usr/bin/env python3
"""Loopback-only, task-scoped atomic CommitFile writer for candidate bootstrap."""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import os
import pathlib
import re
import stat
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from candidate_common import (
CandidateError,
atomic_json,
authorize_bearer,
canonical,
is_oid,
load_json,
now_epoch,
public_error,
read_credential,
read_request_json,
require_loopback,
send_json,
sha256_text,
validate_grant_window,
)
ALLOWED_OPERATIONS = {
"file.read.allowlisted",
"commit_file.atomic",
"git.diff.read",
"git.push.patch_branch",
"test.execute",
"artifact.build",
}
DENIED_OPERATIONS = {
"main.write",
"force_push",
"tag.write",
"baseline.write",
"registry.write",
"production.deploy",
"production.rollback",
"secret.export",
}
BRANCH_RE = re.compile(r"^(?!/)(?!.*(?:\.\.|@\{|//|[~^:?*\[\]\\\s]))(?!.*\/$)[A-Za-z0-9._/-]{1,240}$")
GITEA_CREDENTIAL_NAME = "gitea_git_http"
GITEA_ASKPASS = """#!/usr/bin/python3
import os
import sys
path = os.environ.get("AIOS_GITEA_CREDENTIAL_FILE", "")
try:
lines = open(path, "r", encoding="utf-8").read().splitlines()
except OSError:
raise SystemExit(1)
if len(lines) != 2 or not all(lines):
raise SystemExit(1)
prompt = " ".join(sys.argv[1:]).lower()
if "username" in prompt:
print(lines[0])
elif "password" in prompt:
print(lines[1])
else:
raise SystemExit(1)
"""
class CandidateWriter:
def __init__(
self,
*,
policy_path: pathlib.Path,
worktree: pathlib.Path,
git_dir: pathlib.Path,
state_path: pathlib.Path,
audit_path: pathlib.Path,
) -> None:
self.policy_path = policy_path
self.policy = load_json(policy_path)
validate_grant_window(self.policy, "aios-session-broker-bootstrap-writer-candidate")
self.token = read_credential("writer_token")
if self.policy.get("token_sha256") != sha256_text(self.token):
raise CandidateError("credential_policy_binding_failed", 503)
self.worktree = worktree.resolve(strict=True)
self.git_dir = git_dir
self.state_path = state_path
self.audit_path = audit_path
self.lock = threading.Lock()
self._validate_policy()
self._initialize_git()
self.state = self._load_state()
def _validate_policy(self) -> None:
for field in ["task_id", "writer_instance_id", "exact_branch", "approved_base_oid"]:
if not isinstance(self.policy.get(field), str) or not self.policy[field]:
raise CandidateError(f"token_policy_{field}_invalid", 503)
branch = self.policy["exact_branch"]
if not BRANCH_RE.fullmatch(branch) or branch.lower() in {"main", "master", "trunk"}:
raise CandidateError("token_policy_branch_rejected", 503)
if not is_oid(self.policy["approved_base_oid"]):
raise CandidateError("token_policy_base_oid_invalid", 503)
operations = set(self.policy.get("exact_operations", []))
denied = set(self.policy.get("denied_operations", []))
if not operations or not operations.issubset(ALLOWED_OPERATIONS) or denied != DENIED_OPERATIONS:
raise CandidateError("token_policy_operations_invalid", 503)
roots = self.policy.get("allowed_path_roots")
if not isinstance(roots, list) or not roots:
raise CandidateError("token_policy_path_roots_invalid", 503)
resolved_roots = [pathlib.Path(item).resolve(strict=False) for item in roots if isinstance(item, str)]
if self.worktree not in resolved_roots:
raise CandidateError("token_policy_worktree_not_allowed", 503)
self.allowed_roots = resolved_roots
def _git(self, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]:
command = [
"/usr/bin/git",
f"--git-dir={self.git_dir}",
f"--work-tree={self.worktree}",
*arguments,
]
environment = {
**os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_NOSYSTEM": "1",
}
credential_directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
credential_path = (
pathlib.Path(credential_directory) / GITEA_CREDENTIAL_NAME
if credential_directory and os.path.isabs(credential_directory)
else None
)
if credential_path and credential_path.exists():
try:
entry = os.lstat(credential_path)
if (
not stat.S_ISREG(entry.st_mode)
or stat.S_ISLNK(entry.st_mode)
or entry.st_mode & 0o022
):
raise OSError
except OSError as exc:
raise CandidateError("BLOCKED_GITEA_MANAGED_CREDENTIAL_REQUIRED", 503) from exc
askpass_path = self.git_dir.parent / "gitea-http-askpass.py"
descriptor = os.open(
askpass_path,
os.O_CREAT | os.O_TRUNC | os.O_WRONLY,
0o700,
)
try:
os.write(descriptor, GITEA_ASKPASS.encode("utf-8"))
os.fsync(descriptor)
finally:
os.close(descriptor)
os.chmod(askpass_path, 0o700)
environment.update(
{
"GIT_ASKPASS": str(askpass_path),
"GIT_ASKPASS_REQUIRE": "force",
"AIOS_GITEA_CREDENTIAL_FILE": str(credential_path),
}
)
result = subprocess.run(
command,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
env=environment,
timeout=120,
)
if check and result.returncode != 0:
raise CandidateError(f"git_{arguments[0].replace('-', '_')}_failed", 409)
return result
def _initialize_git(self) -> None:
if not self.git_dir.exists():
source = self.policy.get("repository_source")
if not isinstance(source, str) or not pathlib.Path(source).is_dir():
raise CandidateError("repository_source_invalid", 503)
self.git_dir.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
result = subprocess.run(
["/usr/bin/git", "clone", "--bare", "--shared", source, str(self.git_dir)],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
timeout=120,
)
if result.returncode != 0:
raise CandidateError("candidate_git_clone_failed", 503)
branch = self.policy["exact_branch"]
base = self.policy["approved_base_oid"]
self._git("config", "core.worktree", str(self.worktree))
self._git("config", "user.name", "AIOS Candidate Writer")
self._git("config", "user.email", "candidate-writer@localhost")
push_url = self.policy.get("push_url")
if isinstance(push_url, str) and push_url:
push_remote = self.policy.get("push_remote")
if push_remote != "gitea-http":
raise CandidateError("push_remote_invalid", 503)
self._git("config", f"remote.{push_remote}.url", push_url)
self._git("symbolic-ref", "HEAD", f"refs/heads/{branch}")
if self._git("rev-parse", "--verify", f"refs/heads/{branch}", check=False).returncode != 0:
self._git("update-ref", f"refs/heads/{branch}", base)
self._git("read-tree", "HEAD")
def _load_state(self) -> dict[str, Any]:
if not self.state_path.exists():
return {"uses": 0, "operations": {}}
value = load_json(self.state_path)
if not isinstance(value.get("uses"), int) or not isinstance(value.get("operations"), dict):
raise CandidateError("writer_state_invalid", 503)
return value
def _persist_state(self) -> None:
atomic_json(self.state_path, self.state)
def _append_audit(self, entry: dict[str, Any]) -> None:
self.audit_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
descriptor = os.open(self.audit_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
try:
os.write(descriptor, f"{canonical(entry)}\n".encode("utf-8"))
os.fsync(descriptor)
finally:
os.close(descriptor)
def _authorize_request(self, client: str, headers: Any) -> None:
require_loopback(client)
current_policy = load_json(self.policy_path)
validate_grant_window(current_policy, "aios-session-broker-bootstrap-writer-candidate")
if current_policy.get("token_sha256") != sha256_text(self.token):
raise CandidateError("credential_policy_binding_failed", 401)
authorize_bearer(headers, self.token)
if self.state["uses"] >= current_policy["max_uses"]:
raise CandidateError("credential_max_uses_exhausted", 401)
def _validate_envelope(self, request: dict[str, Any]) -> tuple[str, str]:
operation_id = request.get("operation_id")
operation = request.get("operation")
if not isinstance(operation_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", operation_id):
raise CandidateError("operation_id_invalid")
if operation not in self.policy["exact_operations"]:
raise CandidateError("operation_not_authorized", 403)
if request.get("task_id") != self.policy["task_id"]:
raise CandidateError("task_id_mismatch", 403)
if request.get("branch") != self.policy["exact_branch"]:
raise CandidateError("branch_mismatch", 403)
expected_oid = request.get("expected_branch_oid")
if not is_oid(expected_oid):
raise CandidateError("expected_branch_oid_required")
actual = self._git("rev-parse", "HEAD").stdout.strip()
if actual != expected_oid:
raise CandidateError("expected_branch_oid_mismatch", 409)
return operation_id, operation
def _target(self, relative: Any) -> tuple[pathlib.Path, str]:
if not isinstance(relative, str) or not relative or pathlib.PurePosixPath(relative).is_absolute():
raise CandidateError("path_invalid")
pure = pathlib.PurePosixPath(relative)
if ".." in pure.parts or ".git" in pure.parts or ".candidate-git" in pure.parts:
raise CandidateError("path_rejected", 403)
target = (self.worktree / pathlib.Path(*pure.parts)).resolve(strict=False)
try:
target.relative_to(self.worktree)
except ValueError as exc:
raise CandidateError("path_escape", 403) from exc
cursor = self.worktree
for part in target.relative_to(self.worktree).parts[:-1]:
cursor /= part
if cursor.exists() and cursor.is_symlink():
raise CandidateError("symlink_path_rejected", 403)
if target.exists() and target.is_symlink():
raise CandidateError("symlink_path_rejected", 403)
return target, pure.as_posix()
def _atomic_commit(self, request: dict[str, Any]) -> dict[str, Any]:
target, relative = self._target(request.get("path"))
encoded = request.get("content_base64")
if not isinstance(encoded, str) or len(encoded) > 2_000_000:
raise CandidateError("content_invalid")
try:
content = base64.b64decode(encoded, validate=True)
content.decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise CandidateError("content_invalid") from exc
message = request.get("commit_message")
if not isinstance(message, str) or not 1 <= len(message.strip()) <= 200 or "\n" in message:
raise CandidateError("commit_message_invalid")
target.parent.mkdir(parents=True, exist_ok=True, mode=0o770)
descriptor, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
temporary_path = pathlib.Path(temporary)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_path, 0o660)
os.replace(temporary_path, target)
directory_fd = os.open(target.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
temporary_path.unlink(missing_ok=True)
self._git("add", "--", relative)
staged = self._git("diff", "--cached", "--name-only", "-z").stdout.split("\0")
staged = [item for item in staged if item]
if staged != [relative]:
self._git("reset", "--", relative, check=False)
raise CandidateError("staged_scope_mismatch", 409)
self._git("commit", "-m", message.strip(), "--", relative)
commit_oid = self._git("rev-parse", "HEAD").stdout.strip()
return {
"operation": "commit_file.atomic",
"path": relative,
"branch": self.policy["exact_branch"],
"commit_oid": commit_oid,
"atomic_commit_verified": True,
}
def _read_file(self, request: dict[str, Any]) -> dict[str, Any]:
target, relative = self._target(request.get("path"))
try:
content = target.read_bytes()
except OSError as exc:
raise CandidateError("file_read_failed", 404) from exc
if len(content) > 1024 * 1024:
raise CandidateError("file_too_large", 413)
return {
"operation": "file.read.allowlisted",
"path": relative,
"content_base64": base64.b64encode(content).decode("ascii"),
}
def _diff(self) -> dict[str, Any]:
result = self._git("diff", "--stat", "HEAD")
return {"operation": "git.diff.read", "stat": result.stdout[:65536]}
def _profile(self, request: dict[str, Any], operation: str) -> dict[str, Any]:
profile_id = request.get("profile_id")
profiles = self.policy.get("command_profiles", {})
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
if not isinstance(profile, dict) or profile.get("operation") != operation:
raise CandidateError("command_profile_rejected", 403)
argv = profile.get("argv")
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv):
raise CandidateError("command_profile_invalid", 503)
result = subprocess.run(
argv,
cwd=self.worktree,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
timeout=int(profile.get("timeout_seconds", 120)),
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
if result.returncode != 0:
raise CandidateError(f"{operation.replace('.', '_')}_failed", 422)
return {
"operation": operation,
"profile_id": profile_id,
"exit_code": result.returncode,
"output_sha256": sha256_text(result.stdout),
}
def _push(self) -> dict[str, Any]:
branch = self.policy["exact_branch"]
remote = self.policy.get("push_remote")
if remote != "gitea-http":
raise CandidateError("push_remote_invalid", 503)
credential_directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
credential_path = (
pathlib.Path(credential_directory) / GITEA_CREDENTIAL_NAME
if credential_directory and os.path.isabs(credential_directory)
else None
)
if credential_path is None or not credential_path.is_file():
raise CandidateError("BLOCKED_GITEA_MANAGED_CREDENTIAL_REQUIRED", 503)
self._git("push", remote, f"refs/heads/{branch}:refs/heads/{branch}")
return {
"operation": "git.push.patch_branch",
"branch": branch,
"commit_oid": self._git("rev-parse", "HEAD").stdout.strip(),
"force_push": False,
}
def operation(self, client: str, headers: Any, request: dict[str, Any]) -> tuple[int, dict[str, Any]]:
with self.lock:
self._authorize_request(client, headers)
operation_id = request.get("operation_id")
if not isinstance(operation_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", operation_id):
raise CandidateError("operation_id_invalid")
digest = sha256_text(canonical(request))
prior = self.state["operations"].get(operation_id)
if prior:
if prior.get("request_sha256") != digest:
raise CandidateError("operation_id_conflict", 409)
return 200, {**prior["result"], "deduplicated": True}
operation_id, operation = self._validate_envelope(request)
if operation == "commit_file.atomic":
result = self._atomic_commit(request)
elif operation == "file.read.allowlisted":
result = self._read_file(request)
elif operation == "git.diff.read":
result = self._diff()
elif operation in {"test.execute", "artifact.build"}:
result = self._profile(request, operation)
elif operation == "git.push.patch_branch":
result = self._push()
else:
raise CandidateError("operation_not_implemented", 403)
self.state["uses"] += 1
self.state["operations"][operation_id] = {
"request_sha256": digest,
"result": result,
"recorded_at": now_epoch(),
}
self._persist_state()
self._append_audit(
{
"schema_version": "aios.candidate-writer-audit/v1",
"task_id": self.policy["task_id"],
"writer_instance_id": self.policy["writer_instance_id"],
"operation_id": operation_id,
"operation": operation,
"branch": self.policy["exact_branch"],
"result_sha256": sha256_text(canonical(result)),
"recorded_at": now_epoch(),
}
)
return 200, result
class Handler(BaseHTTPRequestHandler):
server_version = "AIOSCandidateWriter/1"
def log_message(self, _format: str, *_arguments: Any) -> None:
return
@property
def writer(self) -> CandidateWriter:
return self.server.writer # type: ignore[attr-defined]
def do_GET(self) -> None: # noqa: N802
try:
require_loopback(self.client_address[0])
if self.path != "/health":
raise CandidateError("not_found", 404)
send_json(
self,
200,
{
"status": "healthy",
"service": "aios-session-broker-bootstrap-writer-candidate",
"bind": "loopback",
"task_id": self.writer.policy["task_id"],
"branch": self.writer.policy["exact_branch"],
"secrets_included": False,
},
)
except Exception as error: # noqa: BLE001
status, body = public_error(error)
send_json(self, status, body)
def do_POST(self) -> None: # noqa: N802
try:
if self.path != "/v1/operations":
raise CandidateError("not_found", 404)
request = read_request_json(self)
status, body = self.writer.operation(self.client_address[0], self.headers, request)
send_json(self, status, body)
except Exception as error: # noqa: BLE001
status, body = public_error(error)
send_json(self, status, body)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--policy", type=pathlib.Path, required=True)
parser.add_argument("--worktree", type=pathlib.Path, required=True)
parser.add_argument("--git-dir", type=pathlib.Path, required=True)
parser.add_argument("--state", type=pathlib.Path, required=True)
parser.add_argument("--audit", type=pathlib.Path, required=True)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=19932)
arguments = parser.parse_args()
if arguments.host != "127.0.0.1" or arguments.port != 19932:
raise CandidateError("candidate_writer_bind_rejected", 503)
writer = CandidateWriter(
policy_path=arguments.policy,
worktree=arguments.worktree,
git_dir=arguments.git_dir,
state_path=arguments.state,
audit_path=arguments.audit,
)
server = ThreadingHTTPServer((arguments.host, arguments.port), Handler)
server.writer = writer # type: ignore[attr-defined]
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,840 @@
from __future__ import annotations
import base64
import json
import os
import pathlib
import stat
import struct
import subprocess
import sys
import tempfile
import time
import unittest
from datetime import datetime, timezone, timedelta
from unittest import mock
SERVICE_ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SERVICE_ROOT))
import bootstrap_candidate_runtime as bootstrap
from candidate_common import CandidateError, atomic_json, canonical, sha256_text # noqa: E402
from candidate_session_broker import ( # noqa: E402
CandidateSessionBroker,
APPROVAL_ENVELOPE_KEYS,
APPROVAL_PAYLOAD_KEYS,
EXPECTED_CAPABILITIES,
FORBIDDEN_OPERATIONS,
contains_secret_field,
)
from candidate_owner_approval_signer import ( # noqa: E402
authenticated_peer,
authorize_approval,
consume_nonce,
sign_payload,
validate_nonce_ledger,
validate_peer_identity,
validate_request,
)
from candidate_writer import CandidateWriter, DENIED_OPERATIONS # noqa: E402
TASK_ID = "AIOS-SESSION-BROKER-BOOTSTRAP-20260725"
BRANCH = "patch/openclaw-real-dispatch-session-broker-20260725"
BASE = "a" * 40
class FakePeerSocket:
def __init__(self, pid: int, uid: int, gid: int):
self.credentials = struct.pack("3i", pid, uid, gid)
def getsockopt(self, _level: int, _option: int, _length: int) -> bytes:
return self.credentials
class OwnerSignerAuthorizationTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.temporary.name)
self.now = datetime.now(timezone.utc).replace(microsecond=0)
self.peer = {
"pid": 4242,
"uid": 21001,
"gid": 21002,
"service_unit": "aios-owner-approval-caller-candidate.service",
}
self.payload = {
"approval_plan_id": "approval-caller-auth-test",
"task_id": TASK_ID,
"task_plan_sha256": "a" * 64,
"repository": "aios/aios",
"branch": BRANCH,
"approved_base_oid": BASE,
"allowed_paths": ["/candidate/tests/caller-auth.txt"],
"approved_capabilities": ["candidate.write", "candidate.test"],
"risk_class": "high",
"not_before": (self.now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"),
"expires_at": (self.now + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
"nonce": f"approval-nonce-v1:{'3' * 64}",
"signing_key_id": "verifier-key",
}
self.store = {
"schema_version": "aios.owner-approval-store.v1",
"records": [
{
"schema_version": "aios.owner-approval-record.v1",
"decision": "approved",
"approval_plan_id": self.payload["approval_plan_id"],
"signing_key_id": self.payload["signing_key_id"],
"authorized_caller": {
"uid": self.peer["uid"],
"gid": self.peer["gid"],
"service_unit": self.peer["service_unit"],
},
"approval_payload": dict(self.payload),
}
],
}
self.revocations = {
"schema_version": "aios.owner-approval-revocations.v1",
"revoked_plan_ids": [],
"revoked_key_ids": [],
}
def tearDown(self) -> None:
self.temporary.cleanup()
def authorize(
self,
*,
payload: dict | None = None,
store: dict | None = None,
revocations: dict | None = None,
peer: dict | None = None,
) -> dict:
return authorize_approval(
payload or self.payload,
key_id="verifier-key",
peer=peer or self.peer,
approval_store=store or self.store,
revocations=revocations or self.revocations,
now=self.now,
)
def test_peer_uid_gid_and_service_are_exact(self) -> None:
validate_peer_identity(
self.peer,
expected_uid=self.peer["uid"],
expected_gid=self.peer["gid"],
expected_service_unit=self.peer["service_unit"],
)
cases = (
("uid", self.peer["uid"] + 1, "owner_signer_peer_uid_rejected"),
("gid", self.peer["gid"] + 1, "owner_signer_peer_gid_rejected"),
("service_unit", "wrong-candidate.service", "owner_signer_peer_service_rejected"),
)
for field, value, error in cases:
changed = dict(self.peer)
changed[field] = value
with self.subTest(field=field), self.assertRaisesRegex(CandidateError, error):
validate_peer_identity(
changed,
expected_uid=self.peer["uid"],
expected_gid=self.peer["gid"],
expected_service_unit=self.peer["service_unit"],
)
def test_peer_pid_reuse_is_rejected(self) -> None:
fake = FakePeerSocket(self.peer["pid"], self.peer["uid"], self.peer["gid"])
with (
mock.patch(
"candidate_owner_approval_signer.proc_starttime",
side_effect=["100", "101"],
),
mock.patch(
"candidate_owner_approval_signer.proc_service_unit",
return_value=self.peer["service_unit"],
),
self.assertRaisesRegex(CandidateError, "owner_signer_peer_pid_reused"),
):
authenticated_peer(
fake,
expected_uid=self.peer["uid"],
expected_gid=self.peer["gid"],
expected_service_unit=self.peer["service_unit"],
)
def test_unknown_denied_and_revoked_approvals_are_rejected(self) -> None:
unknown = dict(self.payload)
unknown["approval_plan_id"] = "approval-unknown"
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_unknown"):
self.authorize(payload=unknown)
denied = json.loads(json.dumps(self.store))
denied["records"][0]["decision"] = "denied"
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_denied"):
self.authorize(store=denied)
revoked = dict(self.revocations)
revoked["revoked_plan_ids"] = [self.payload["approval_plan_id"]]
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_revoked"):
self.authorize(revocations=revoked)
def test_not_yet_valid_and_expired_approvals_are_rejected(self) -> None:
future_payload = dict(self.payload)
future_payload["not_before"] = (self.now + timedelta(minutes=1)).isoformat().replace("+00:00", "Z")
future_payload["expires_at"] = (self.now + timedelta(minutes=5)).isoformat().replace("+00:00", "Z")
future_store = json.loads(json.dumps(self.store))
future_store["records"][0]["approval_payload"] = future_payload
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_not_yet_valid"):
self.authorize(payload=future_payload, store=future_store)
expired_payload = dict(self.payload)
expired_payload["not_before"] = (self.now - timedelta(minutes=5)).isoformat().replace("+00:00", "Z")
expired_payload["expires_at"] = (self.now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z")
expired_store = json.loads(json.dumps(self.store))
expired_store["records"][0]["approval_payload"] = expired_payload
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_expired"):
self.authorize(payload=expired_payload, store=expired_store)
def test_caller_and_every_payload_binding_are_exact(self) -> None:
caller_changed = dict(self.peer)
caller_changed["uid"] += 1
with self.assertRaisesRegex(CandidateError, "owner_signer_approval_caller_rejected"):
self.authorize(peer=caller_changed)
mutations = {
"task_id": "different-task",
"task_plan_sha256": "b" * 64,
"repository": "aios/different",
"branch": f"{BRANCH}-tampered",
"approved_base_oid": "b" * 40,
"allowed_paths": ["/candidate/tests/tampered.txt"],
"approved_capabilities": ["candidate.write", "main.write"],
"risk_class": "critical",
"not_before": (self.now - timedelta(minutes=2)).isoformat().replace("+00:00", "Z"),
"expires_at": (self.now + timedelta(minutes=6)).isoformat().replace("+00:00", "Z"),
"nonce": f"approval-nonce-v1:{'4' * 64}",
"signing_key_id": "different-key",
}
for field, value in mutations.items():
changed = dict(self.payload)
changed[field] = value
with self.subTest(field=field), self.assertRaisesRegex(
CandidateError,
"owner_signer_approval_payload_rejected",
):
self.authorize(payload=changed)
def test_nonce_replay_is_persistent(self) -> None:
ledger = self.root / "nonce-ledger.sqlite3"
validate_nonce_ledger(ledger)
consume_nonce(ledger, self.payload)
with self.assertRaisesRegex(CandidateError, "owner_signer_nonce_replay"):
consume_nonce(ledger, self.payload)
validate_nonce_ledger(ledger)
with self.assertRaisesRegex(CandidateError, "owner_signer_nonce_replay"):
consume_nonce(ledger, self.payload)
def test_service_template_uses_only_dedicated_groups(self) -> None:
unit = (SERVICE_ROOT / "aios-owner-approval-signer-candidate.service").read_text(encoding="utf-8")
self.assertIn("Group=aios-owner-signer-candidate", unit)
self.assertIn("SupplementaryGroups=aios-owner-approval-caller-candidate", unit)
self.assertNotIn("Group=gitops", unit)
def credential_directory(root: pathlib.Path, **values: str) -> pathlib.Path:
directory = root / "credentials"
directory.mkdir(mode=0o700)
for name, value in values.items():
path = directory / name
path.write_text(f"{value}\n", encoding="utf-8")
path.chmod(0o600)
return directory
def common_policy(token: str, *, audience: str, allowed_root: pathlib.Path) -> dict:
now = int(time.time())
return {
"schema_version": "aios.token-policy.v2",
"task_id": TASK_ID,
"writer_instance_id": "writer-candidate-test",
"audience": audience,
"source_cidr": "127.0.0.1",
"repository": "aios/aios",
"repository_source": str(allowed_root),
"exact_branch": BRANCH,
"approved_base_oid": BASE,
"risk_class": "high",
"exact_operations": sorted(EXPECTED_CAPABILITIES),
"denied_operations": sorted(FORBIDDEN_OPERATIONS),
"allowed_path_roots": [str(allowed_root)],
"not_before": now - 10,
"expires_at": now + 600,
"max_uses": 100,
"token_sha256": sha256_text(token),
}
class CandidateBrokerTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.temporary.name)
self.token = "broker-control-test-token"
self.credentials = credential_directory(self.root, broker_control_token=self.token)
self.policy = common_policy(
self.token,
audience="aios-openclaw-session-broker-candidate",
allowed_root=self.root,
)
self.policy.update(
{
"approval_id": "approval-test",
"lease_seconds": 30,
}
)
self.approval_private_key = self.root / "approval-key.pem"
self.approval_public_key = self.root / "approval-key.pub"
subprocess.run(
["openssl", "genpkey", "-algorithm", "ED25519", "-out", str(self.approval_private_key)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"openssl", "pkey", "-in", str(self.approval_private_key),
"-pubout", "-out", str(self.approval_public_key),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.approval_public_key.chmod(0o644)
self.approval_revocations = self.root / "approval-revocations.json"
self.approval_revocations.write_text("[]", encoding="utf-8")
self.policy.update(
{
"approval_verifier": {
"key_id": "verifier-key",
"public_key_path": str(self.approval_public_key),
"revocations_path": str(self.approval_revocations),
}
}
)
self.policy_path = self.root / "broker-policy.json"
atomic_json(self.policy_path, self.policy)
self.environment = mock.patch.dict(os.environ, {"CREDENTIALS_DIRECTORY": str(self.credentials)})
self.environment.start()
real_lstat = os.lstat
def fake_lstat(path: str | os.PathLike[str]) -> os.stat_result:
if str(path) in {str(self.approval_public_key), str(self.approval_revocations)}:
return os.stat_result((stat.S_IFREG | 0o644, 0, 0, 0, 0, 0, 0, 0, 0, 0))
return real_lstat(path)
self.broker_lstat = mock.patch("candidate_session_broker.os.lstat", side_effect=fake_lstat)
self.broker_lstat.start()
self.broker = CandidateSessionBroker(
policy_path=self.policy_path,
database_path=self.root / "broker.sqlite3",
projection_root=self.root / "sessions",
writer_endpoint="http://127.0.0.1:19932",
)
self.verifier_patch = mock.patch.object(
self.broker,
"_verify_approval_signature",
return_value=None,
)
self.verifier_patch.start()
self.writer_probe = mock.patch.object(self.broker, "writer_reachable", return_value=True)
self.writer_probe.start()
def tearDown(self) -> None:
self.writer_probe.stop()
self.verifier_patch.stop()
self.broker.connection.close()
self.broker_lstat.stop()
self.environment.stop()
self.temporary.cleanup()
def approval_payload(self, **overrides: object) -> dict[str, object]:
now = datetime.now(timezone.utc).replace(microsecond=0)
payload: dict[str, object] = {
"approval_plan_id": "approval-test",
"task_id": TASK_ID,
"task_plan_sha256": "a" * 64,
"repository": "aios/aios",
"branch": BRANCH,
"approved_base_oid": BASE,
"allowed_paths": [str(self.root)],
"approved_capabilities": sorted(EXPECTED_CAPABILITIES),
"risk_class": "high",
"not_before": now.isoformat().replace("+00:00", "Z"),
"expires_at": (now + timedelta(minutes=4)).isoformat().replace("+00:00", "Z"),
"nonce": f"approval-nonce-v1:{'2' * 64}",
"signing_key_id": "verifier-key",
}
payload.update(overrides)
return payload
def approval_envelope(self, **overrides: object) -> dict[str, object]:
envelope: dict[str, object] = {
"approval_plan_id": "approval-test",
"approval_key_id": "verifier-key",
"approval_signature": "c2lnbmF0dXJl", # base64url('signature')
"approval_payload": self.approval_payload(),
}
envelope.update(overrides)
return envelope
def sign_approval_payload(self, payload: dict[str, object]) -> str:
payload_path = self.root / "approval-payload.json"
signature_path = self.root / "approval-signature.bin"
payload_path.write_text(canonical(payload), encoding="utf-8")
subprocess.run(
[
"openssl", "pkeyutl", "-sign", "-inkey", str(self.approval_private_key),
"-rawin", "-in", str(payload_path), "-out", str(signature_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return base64.urlsafe_b64encode(signature_path.read_bytes()).decode("ascii").rstrip("=")
def headers(self, token: str | None = None) -> dict[str, str]:
return {"Authorization": f"Bearer {token or self.token}"}
def request(self, **overrides: object) -> dict:
value = {
"task_id": TASK_ID,
"parent_session_id": "parent-session-test",
"requested_role": "dedicated_platform_writer",
"required_capabilities": sorted(EXPECTED_CAPABILITIES),
"repository": "aios/aios",
"branch": BRANCH,
"approved_base_oid": BASE,
"allowed_paths": [str(self.root)],
"credential_policy": {"ttl_seconds": 600, "max_uses": 10},
"result_delivery": {
"channel": "supervisor",
"recipient_binding": f"principal:sha256:{'1' * 64}",
"content_type": "text/plain",
"encoding": "utf-8",
"image_only": False,
},
"idempotency_key": "operation-test",
"owner_provenance_id": "prov-test-owner",
}
plan = {
key: value[key]
for key in [
"task_id", "parent_session_id", "requested_role",
"required_capabilities", "repository", "branch",
"approved_base_oid", "allowed_paths", "credential_policy",
"result_delivery",
]
}
value["approval_envelope"] = self.approval_envelope(
approval_payload=self.approval_payload(
task_plan_sha256=sha256_text(canonical(plan)),
)
)
value.update(overrides)
return value
def test_provision_is_real_and_idempotent_without_secret(self) -> None:
status, first = self.broker.provision("127.0.0.1", self.headers(), self.request())
self.assertEqual(status, 201)
self.assertFalse(first["simulation"])
self.assertTrue(first["broker_session_verified"])
self.assertFalse(first["openclaw_native_child_session_verified"])
self.assertFalse(first["codex_child_session_verified"])
self.assertEqual(first["worker_type"], "local_candidate_test_worker")
self.assertTrue(first["credential_reference"].startswith("secret-ref://"))
self.assertFalse(contains_secret_field(first))
status, second = self.broker.provision("127.0.0.1", self.headers(), self.request())
self.assertEqual(status, 200)
self.assertTrue(second["deduplicated"])
self.assertEqual(first["child_session_id"], second["child_session_id"])
def test_heartbeat_close_and_post_revoke_401(self) -> None:
_, provisioned = self.broker.provision("127.0.0.1", self.headers(), self.request())
session_id = provisioned["child_session_id"]
session_token = (self.root / "sessions" / f"{session_id}.token").read_text(encoding="utf-8").strip()
heartbeat = self.broker.heartbeat("127.0.0.1", self.headers(session_token), session_id)
self.assertEqual(heartbeat["status"], "active")
closed = self.broker.close("127.0.0.1", self.headers(), session_id)
self.assertTrue(closed["credential_revoked"])
with self.assertRaisesRegex(CandidateError, "session_credential_revoked"):
self.broker.heartbeat("127.0.0.1", self.headers(session_token), session_id)
def test_broker_restart_recovers_session(self) -> None:
_, provisioned = self.broker.provision("127.0.0.1", self.headers(), self.request())
session_id = provisioned["child_session_id"]
self.broker.connection.close()
recovered = CandidateSessionBroker(
policy_path=self.policy_path,
database_path=self.root / "broker.sqlite3",
projection_root=self.root / "sessions",
writer_endpoint="http://127.0.0.1:19932",
)
with mock.patch.object(recovered, "writer_reachable", return_value=True):
value = recovered.public_session("127.0.0.1", self.headers(), session_id)
recovered.connection.close()
self.assertEqual(value["status"], "provisioned")
self.assertEqual(value["child_session_id"], session_id)
def test_broker_restart_with_signature_verifier(self) -> None:
self.approval_revocations.write_text(
json.dumps({"revocations": ["approval-test"]}),
encoding="utf-8",
)
recovered = CandidateSessionBroker(
policy_path=self.policy_path,
database_path=self.root / "broker.sqlite3",
projection_root=self.root / "sessions",
writer_endpoint="http://127.0.0.1:19932",
)
recovered.connection.close()
self.assertEqual(
APPROVAL_ENVELOPE_KEYS,
{"approval_plan_id", "approval_key_id", "approval_signature", "approval_payload"},
)
self.assertEqual(
APPROVAL_PAYLOAD_KEYS,
{
"approval_plan_id",
"task_id",
"task_plan_sha256",
"repository",
"branch",
"approved_base_oid",
"allowed_paths",
"approved_capabilities",
"risk_class",
"not_before",
"expires_at",
"nonce",
"signing_key_id",
},
)
def test_real_ed25519_approval_signature_verifies(self) -> None:
payload = self.approval_payload()
signature = self.sign_approval_payload(payload)
self.verifier_patch.stop()
try:
self.broker._verify_approval_signature(
approval_key_id="verifier-key",
approval_signature=signature,
approval_payload=payload,
)
finally:
self.verifier_patch.start()
def test_external_owner_signer_canonical_binding_and_signature(self) -> None:
payload = self.approval_payload()
request = {
"schema_version": "aios.owner-approval-sign-request.v1",
"operation": "sign_owner_approval",
"approval_plan_id": "approval-test",
"signing_key_id": "verifier-key",
"canonical_payload": canonical(payload),
"approval_payload": payload,
}
self.assertEqual(validate_request(request, "verifier-key"), payload)
self.approval_private_key.chmod(0o600)
signature = sign_payload(payload, self.approval_private_key)
self.verifier_patch.stop()
try:
self.broker._verify_approval_signature(
approval_key_id="verifier-key",
approval_signature=signature,
approval_payload=payload,
)
finally:
self.verifier_patch.start()
tampered = dict(request)
tampered["canonical_payload"] = "{}"
with self.assertRaisesRegex(CandidateError, "owner_signer_canonical_payload_rejected"):
validate_request(tampered, "verifier-key")
def test_main_capability_expansion_and_secret_input_rejected(self) -> None:
with self.assertRaisesRegex(CandidateError, "branch_rejected"):
self.broker.provision("127.0.0.1", self.headers(), self.request(branch="main"))
expanded = sorted(EXPECTED_CAPABILITIES | {"main.write"})
with self.assertRaisesRegex(CandidateError, "capability_expansion_rejected"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(required_capabilities=expanded),
)
with self.assertRaisesRegex(CandidateError, "provision_schema_rejected"):
self.broker.provision("127.0.0.1", self.headers(), self.request(token="forbidden"))
def test_approval_nonce_replay_and_signing_key_binding_rejected(self) -> None:
self.broker.provision("127.0.0.1", self.headers(), self.request())
replay = self.request(idempotency_key="operation-replayed-nonce")
with self.assertRaisesRegex(CandidateError, "approval_nonce_replay"):
self.broker.provision("127.0.0.1", self.headers(), replay)
mismatched_payload = self.approval_payload(signing_key_id="different-key")
with self.assertRaisesRegex(CandidateError, "approval_payload_signing_key_id_rejected"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(
idempotency_key="operation-key-mismatch",
approval_envelope=self.approval_envelope(
approval_payload=mismatched_payload,
),
),
)
def test_provision_blocks_missing_verifier(self) -> None:
policy = json.loads(self.policy_path.read_text(encoding="utf-8"))
del policy["approval_verifier"]
atomic_json(self.policy_path, policy)
with self.assertRaisesRegex(CandidateError, "BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE"):
self.broker = CandidateSessionBroker(
policy_path=self.policy_path,
database_path=self.root / "broker.sqlite3",
projection_root=self.root / "sessions",
writer_endpoint="http://127.0.0.1:19932",
)
def test_provision_blocks_bad_signature_binding_time_and_revocation(self) -> None:
with self.assertRaisesRegex(CandidateError, "approval_signature_invalid"):
with mock.patch.object(self.broker, "_verify_approval_signature", side_effect=CandidateError("approval_signature_invalid", 403)):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(),
)
with self.assertRaisesRegex(CandidateError, "approval_payload_branch_rejected"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(approval_envelope=self.approval_envelope(
approval_payload=self.approval_payload(branch="other")
)),
)
with self.assertRaisesRegex(CandidateError, "approval_payload_task_plan_digest_rejected"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(
approval_envelope=self.approval_envelope(
approval_payload=self.approval_payload(task_plan_sha256="b" * 64)
)
),
)
with self.assertRaisesRegex(CandidateError, "result_delivery_invalid"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(
result_delivery={
"channel": "supervisor",
"recipient_binding": "untrusted-recipient",
"content_type": "text/plain",
"encoding": "utf-8",
"image_only": False,
}
),
)
future_request = self.request()
future_payload = dict(future_request["approval_envelope"]["approval_payload"])
future_payload.update(
{
"not_before": "2099-01-01T00:00:00Z",
"expires_at": "2099-01-01T00:10:00Z",
}
)
future_request["approval_envelope"] = self.approval_envelope(
approval_payload=future_payload,
)
with self.assertRaisesRegex(CandidateError, "approval_payload_not_current"):
self.broker.provision(
"127.0.0.1",
self.headers(),
future_request,
)
self.approval_revocations.write_text(
json.dumps(["approval-test"]),
encoding="utf-8",
)
with self.assertRaisesRegex(CandidateError, "approval_payload_revoked"):
self.broker.provision(
"127.0.0.1",
self.headers(),
self.request(),
)
class CandidateWriterTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.temporary.name)
self.source = self.root / "source.git"
self.seed = self.root / "seed"
subprocess.run(["git", "init", str(self.seed)], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "-C", str(self.seed), "config", "user.name", "test"], check=True)
subprocess.run(["git", "-C", str(self.seed), "config", "user.email", "test@localhost"], check=True)
(self.seed / "README.md").write_text("base\n", encoding="utf-8")
subprocess.run(["git", "-C", str(self.seed), "add", "README.md"], check=True)
subprocess.run(["git", "-C", str(self.seed), "commit", "-m", "base"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "clone", "--bare", str(self.seed), str(self.source)], check=True, stdout=subprocess.DEVNULL)
self.worktree = self.root / "worktree"
subprocess.run(["git", "clone", str(self.source), str(self.worktree)], check=True, stdout=subprocess.DEVNULL)
self.base = subprocess.run(
["git", "-C", str(self.worktree), "rev-parse", "HEAD"],
check=True,
text=True,
stdout=subprocess.PIPE,
).stdout.strip()
subprocess.run(["git", "-C", str(self.worktree), "checkout", "-b", BRANCH], check=True, stdout=subprocess.DEVNULL)
self.token = "writer-test-token"
self.credentials = credential_directory(self.root, writer_token=self.token)
self.policy = common_policy(
self.token,
audience="aios-session-broker-bootstrap-writer-candidate",
allowed_root=self.worktree,
)
self.policy.update(
{
"approved_base_oid": self.base,
"repository_source": str(self.source),
"exact_operations": sorted(EXPECTED_CAPABILITIES),
"denied_operations": sorted(DENIED_OPERATIONS),
"push_remote": "gitea",
"command_profiles": {},
}
)
self.policy_path = self.root / "writer-policy.json"
atomic_json(self.policy_path, self.policy)
self.environment = mock.patch.dict(os.environ, {"CREDENTIALS_DIRECTORY": str(self.credentials)})
self.environment.start()
self.writer = CandidateWriter(
policy_path=self.policy_path,
worktree=self.worktree,
git_dir=self.root / "candidate.git",
state_path=self.root / "writer-state.json",
audit_path=self.root / "writer-audit.jsonl",
)
def tearDown(self) -> None:
self.environment.stop()
self.temporary.cleanup()
def envelope(self, **overrides: object) -> dict:
value = {
"operation_id": "commit-test",
"operation": "commit_file.atomic",
"task_id": TASK_ID,
"branch": BRANCH,
"expected_branch_oid": self.base,
"path": "candidate.txt",
"content_base64": base64.b64encode(b"candidate\n").decode("ascii"),
"commit_message": "test(candidate): atomic commit",
}
value.update(overrides)
return value
def test_atomic_commit_and_idempotency(self) -> None:
status, first = self.writer.operation(
"127.0.0.1",
{"Authorization": f"Bearer {self.token}"},
self.envelope(),
)
self.assertEqual(status, 200)
self.assertTrue(first["atomic_commit_verified"])
self.assertEqual(len(first["commit_oid"]), 40)
status, second = self.writer.operation(
"127.0.0.1",
{"Authorization": f"Bearer {self.token}"},
self.envelope(),
)
self.assertEqual(status, 200)
self.assertTrue(second["deduplicated"])
def test_full_expected_oid_and_revocation_required(self) -> None:
with self.assertRaisesRegex(CandidateError, "expected_branch_oid_required"):
self.writer.operation(
"127.0.0.1",
{"Authorization": f"Bearer {self.token}"},
self.envelope(expected_branch_oid="abc"),
)
policy = json.loads(self.policy_path.read_text(encoding="utf-8"))
policy["expires_at"] = int(time.time()) - 1
atomic_json(self.policy_path, policy)
with self.assertRaisesRegex(CandidateError, "credential_expired"):
self.writer.operation(
"127.0.0.1",
{"Authorization": f"Bearer {self.token}"},
self.envelope(),
)
class BootstrapHardeningTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.temporary.name)
self.bootstrap_path = pathlib.Path(__file__).resolve().parents[1] / "bootstrap_candidate_runtime.py"
def tearDown(self) -> None:
self.temporary.cleanup()
def test_bootstrap_source_scans_hardening(self) -> None:
source = self.bootstrap_path.read_text(encoding="utf-8")
for fragment in [
"sqlite" + "3",
"GITEA_" + "DATABASE",
"GITEA_BINARY",
"GITEA_CONFIG",
"GITEA_TEMP_TOKEN_NAME",
"temporary_gitea_token",
"remove_temporary_gitea_token",
"ssh-" + "keyscan",
"--raw",
"gitea admin",
]:
self.assertNotIn(fragment, source)
def test_missing_gitea_api_credential(self) -> None:
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(SystemExit, "BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED"):
bootstrap._load_gitea_api_credential()
def test_gitea_api_whitelist(self) -> None:
with mock.patch.object(bootstrap.urllib.request, "urlopen") as urlopen:
with self.assertRaisesRegex(SystemExit, "BLOCKED_GITEA_API_CALL_NOT_ALLOWED"):
bootstrap.gitea_api("token", "DELETE", "/api/v1/settings")
with self.assertRaisesRegex(SystemExit, "BLOCKED_GITEA_API_CALL_NOT_ALLOWED"):
bootstrap.gitea_api("token", "PUT", "/repos/aios/aios/keys")
self.assertFalse(urlopen.called)
def test_gitea_host_key_mismatch(self) -> None:
known_hosts = pathlib.Path(self.root) / "gitea-ssh-known-hosts"
known_hosts.write_text("bad-host", encoding="utf-8")
known_hosts.chmod(0o644)
def fake_lstat(_path: str | os.PathLike[str]) -> os.stat_result:
return os.stat_result((stat.S_IFREG | 0o644, 0, 0, 0, 0, 0, 0, 0, 0, 0))
class CommandResult:
returncode = 0
stdout = "2048 SHA256:WRONG fingerprint g.cnnetmkt.top (ED25519)\n"
stderr = ""
with mock.patch("bootstrap_candidate_runtime.GITEA_KNOWN_HOSTS", known_hosts):
with mock.patch("bootstrap_candidate_runtime.os.lstat", side_effect=fake_lstat):
with mock.patch.object(bootstrap.subprocess, "run", return_value=CommandResult()):
with self.assertRaisesRegex(SystemExit, "BLOCKED_GITEA_SSH_HOST_KEY_MISMATCH"):
bootstrap._assert_gitea_known_hosts()
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -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
@@ -27,6 +29,8 @@ from aios_v2 import (
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
@@ -67,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)
@@ -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()
+9 -3
View File
@@ -41,6 +41,7 @@ from aios_v2 import (
RequestContext,
RunProvenance,
SQLiteProvenanceStore,
TaskState,
digest_payload,
)
@@ -288,12 +289,12 @@ class ProvenanceTestCase(unittest.TestCase):
session_id="session-prov-runtime",
policy_version="policy-v1",
)
service.create_tenant(
service.register_tenant(
request,
tenant_id=self.context.tenant_id,
display_name="Tenant A",
)
agent = service.create_agent(
agent = service.register_agent(
request,
agent_id=self.context.agent_id,
display_name="Hermes Agent",
@@ -308,7 +309,12 @@ class ProvenanceTestCase(unittest.TestCase):
payload={"target": "candidate"},
idempotency_key="prov-runtime-001",
)
task = service.approve_task(request, task.task_id, expected_revision=task.revision)
task = service.transition_task(
request,
task_id=task.task_id,
target_state=TaskState.APPROVED,
expected_revision=task.revision,
)
orchestrator = ExecutionOrchestrator(
service,
hermes=HermesAdapter(),
@@ -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)
+346
View File
@@ -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()
+253
View File
@@ -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())
+393
View File
@@ -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())
+151
View File
@@ -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()
+14
View File
@@ -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"
}