Compare commits

..
Author SHA1 Message Date
Codex 8131cd60a2 docs(codex): record blocked callback transport live review 2026-07-18 15:52:42 +08:00
Codex 59edc85930 feat(codex): harden callback transport and pending recovery 2026-07-18 15:50:42 +08:00
Codex 815e39f36c fix(codex): harden workspace trigger protocol 2026-07-18 14:51:57 +08:00
Codex c679c8394f fix(codex): make central GPT health probing single-flight 2026-07-18 13:44:06 +08:00
Codex 9ec60c162a fix(quorum): treat persisted blocked state as healthy cycle 2026-07-18 11:59:27 +08:00
Codex 415d736f88 fix(quorum): read authoritative bare refs in sandbox 2026-07-18 11:57:52 +08:00
Codex 20bfb3e773 fix(quorum): restore controller executable mode 2026-07-18 11:56:31 +08:00
Codex cf211ac4ac fix(quorum): validate and trust exact Git ref source 2026-07-18 11:56:18 +08:00
Codex a76153c637 fix(quorum): make health receipts group-readable 2026-07-18 11:53:50 +08:00
Codex 3ddab59f65 fix(codex): allow read-only probe WAL access 2026-07-18 11:47:02 +08:00
Codex 4b88d90531 fix(codex): preserve watcher access to probe commands 2026-07-18 11:42:37 +08:00
Codex 2a80c8ad7a fix(codex): bound gateway polling work units 2026-07-18 10:42:51 +08:00
Codex 1e00541e50 fix(codex): bound central health event scheduling 2026-07-18 10:36:22 +08:00
Codex cb4f13601c fix(codex): fail over model trigger channel safely 2026-07-18 10:32:12 +08:00
Codex 6170627542 fix(codex): verify existing shared queue without mutation 2026-07-18 10:26:16 +08:00
Codex 56827247f0 fix(codex): align gateway sandbox with durable queue 2026-07-18 10:24:10 +08:00
Codex 84714c712a fix(codex): recover failed gateway activation safely 2026-07-18 10:20:09 +08:00
Codex 87fe8aea91 fix(codex): make gateway activation transactional 2026-07-18 10:17:41 +08:00
Codex e850db7437 feat(codex): close GPT plus executor unattended automation loop 2026-07-18 10:15:05 +08:00
Codex e116146341 merge: integrate GPT plus one-executor governance and quorum 2026-07-18 09:43:06 +08:00
Codex df78b26437 feat(orchestration): implement GPT plus one-executor quorum controller 2026-07-18 05:40:50 +08:00
Codex 87a5fac40d chore(governance): adopt GPT plus one-executor automation quorum 2026-07-18 05:01:34 +08:00
Codex 08fe81efee chore(orchestration): dispatch OpenClaw three-party self-healing and release supervision 2026-07-18 04:54:21 +08:00
Codex c3984ca2b4 fix(codex): isolate watcher runtime directory ownership 2026-07-18 03:39:45 +08:00
Codex 0c7e2d3b89 feat(codex): add persistent automatic command watcher 2026-07-18 03:36:46 +08:00
Codex 1b3b74fd62 merge(codex): integrate AIOS gateway with baseline remediation 2026-07-18 03:01:30 +08:00
Codex 378cc4d56a fix(codex): restore hardened WeChat send backend lineage 2026-07-18 03:00:32 +08:00
Codex 95fc4d37f0 feat(codex): add reliable AIOS central orchestration gateway 2026-07-18 02:26:14 +08:00
Codex 9195470ceb chore(orchestration): dispatch adaptive OpenClaw and Codex work units 2026-07-17 22:13:38 +08:00
Codex cbe33ecab8 chore(governance): refresh adaptive dual-agent production contract 2026-07-17 22:13:38 +08:00
Codex e3fc48e5b9 chore(governance): refresh adaptive dual-agent production contract 2026-07-17 22:04:03 +08:00
Codex ed3bcf76c7 chore(governance): add shared V2R1 production execution contract 2026-07-17 21:24:20 +08:00
138 changed files with 12450 additions and 10159 deletions
@@ -0,0 +1,76 @@
{
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"created_at": "2026-07-18T04:58:00+00:00",
"schema_version": 2,
"state_writer": "gateway",
"work_units": [
{
"id": "R0-DISCOVER-SECRET-PROVIDER",
"track": "R_RUNNER",
"gate": "R0_RUNNER_LIVE_RECOVERY",
"primary": "openclaw",
"fallback": "codex",
"status": "READY",
"quorum": "requires_gpt_plus_one_executor"
},
{
"id": "P2-INVENTORY-REAL-EVIDENCE",
"track": "P_PERSONAL_WEIXIN",
"gate": "P2_REAL_B3_EVIDENCE",
"primary": "openclaw",
"fallback": "codex",
"status": "READY",
"quorum": "requires_gpt_plus_one_executor"
},
{
"id": "P1-POST-CONTRACT-REACK",
"track": "P_PERSONAL_WEIXIN",
"gate": "P1_REPAIR3_CODE",
"primary": "codex",
"fallback": "openclaw",
"status": "READY",
"quorum": "requires_gpt_plus_one_executor"
},
{
"id": "Q0-GPT-HEALTH-INIT",
"track": "_QUORUM",
"gate": "Q0_GPT_AND_EXECUTOR_HEALTH",
"primary": "gateway",
"fallback": null,
"status": "READY",
"quorum": "gateway_initiated"
},
{
"id": "Q1-ACTIVE-EXECUTOR-ELECTION",
"track": "_QUORUM",
"gate": "Q1_ELECT_ACTIVE_EXECUTOR",
"primary": "gateway",
"fallback": null,
"status": "PENDING",
"depends_on": "Q0-GPT-HEALTH-INIT",
"quorum": "gateway_initiated"
},
{
"id": "Q2-FAILOVER-HEALTHCHECK",
"track": "_QUORUM",
"gate": "Q2_STANDBY_HEALTH_MONITOR",
"primary": "gateway",
"fallback": null,
"status": "PENDING",
"depends_on": "Q1-ACTIVE-EXECUTOR-ELECTION",
"quorum": "log_only_if_active_ok"
},
{
"id": "Q3-UNATTENDED-ACCEPTANCE-MATRIX",
"track": "_QUORUM",
"gate": "A0_AUTOMATION_CLOSED",
"primary": "gateway",
"fallback": null,
"status": "PENDING_LIVE_EVIDENCE",
"depends_on": "Q2-FAILOVER-HEALTHCHECK",
"quorum": "requires_gpt_plus_one_executor",
"required_scenarios": 16,
"may_mark_pass_without_live_evidence": false
}
]
}
@@ -0,0 +1,248 @@
{
"adaptive_dispatch": {
"codex_first": [
"complex_code",
"security_boundary",
"architecture",
"high_risk_review",
"strong_model_materially_better"
],
"default_orchestrator": "openclaw",
"dynamic_executor_selection": {
"large_repetitive_infrastructure_monitoring": "openclaw",
"complex_code_security_architecture": "codex",
"both_healthy_active": "assign_by_preference",
"one_healthy_active": "use_healthy_only",
"both_unhealthy_blocked": true
},
"max_attempts_per_work_unit": 2,
"on_timeout": [
"mark_TIMEOUT",
"preserve_logs",
"switch_agent"
],
"openclaw_first": [
"quality_close",
"large_or_repetitive",
"long_running",
"infra_or_git_inventory",
"cost_sensitive"
],
"single_state_writer_lease_required": true,
"strict_role_partition": false,
"state_writer": "gateway",
"timeout_can_pass": false
},
"authoritative_state": {
"b3": "BLOCKED_EVIDENCE_REQUIRED",
"b4": "NOT_STARTED",
"baseline": "V1R4C6",
"production_release": "NOT_STARTED",
"release": "paused",
"repair3": {
"base": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
"candidate": "PASS_CANDIDATE_PREPARED",
"commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4",
"exact4": "PASS",
"result": "49a9c2e0cc898ecf2bbd45f454e007644d7f3045"
},
"runner": {
"artifact_sha256": "85c472c05a1e55a5899b215d3cd41df53912787de529ac9cb3d5308da4158130",
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
"live": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
"offline": "PASS",
"result": "e789632e359e9232d8ae081e7c4f23ed7b615a9d"
}
},
"central_gpt": {
"required": "HEALTHY",
"health_definition": {
"live_probe_required": true,
"workspace_agent_validated_live_or_responses_validated_live": true,
"configuration_or_credential_presence_is_insufficient": true,
"unknown_is_not_healthy": true
},
"independent_review": {
"single_executor_mode": true,
"required_evidence": [
"committed_git_tree",
"full_commit_oid",
"test_logs_with_rc",
"gitleaks",
"artifact_digest",
"worktree_bare_gitea_oid",
"live_runtime_evidence"
],
"model_judgment_insufficient": true
}
},
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
"executor_quorum": {
"minimum_healthy": 1,
"candidates": [
"openclaw",
"codex"
],
"quorum_pass": [
"GPT_HEALTHY_AND_OPENCLAW_HEALTHY",
"GPT_HEALTHY_AND_CODEX_HEALTHY"
],
"quorum_block": [
"GPT_UNHEALTHY",
"GPT_HEALTHY_BUT_BOTH_EXECUTORS_UNAVAILABLE",
"GPT_OR_SOLE_EXECUTOR_UNKNOWN"
]
},
"automation_closed_success_conditions": [
"CENTRAL_GPT_HEALTH=PASS",
"EXECUTOR_QUORUM=PASS",
"ACTIVE_EXECUTOR=openclaw|codex",
"EVENT_TRIGGER=PASS",
"POLLING=PASS",
"FAILOVER=PASS",
"LIFECYCLE_SYNC=PASS",
"TWO_CYCLE_UNATTENDED_LOOP=PASS",
"RESTART_RECOVERY=PASS",
"AUTOMATION_CLOSED=PASS"
],
"gateway_lifecycle_events": [
"TASK_DISPATCHED",
"TASK_STARTED",
"TASK_ENDED",
"DECISION_APPLIED",
"PROJECT_SUMMARY",
"PROJECT_COMPLETED"
],
"facts_policy": {
"accept": [
"committed_git_tree",
"full_oid",
"worktree_bare_gitea_oid_match",
"test_log_with_rc",
"immutable_digest",
"result_only_commit",
"live_evidence"
],
"reject": [
"verbal_claim",
"readme_only",
"local_only_commit",
"environment_variable_as_secret_ref",
"compile_only",
"timeout_as_pass",
"unrelated_repo",
"fabricated_evidence"
]
},
"forbidden_git": [
"reset",
"amend",
"rebase",
"force_push",
"direct_main_push",
"direct_bare_ref_mutation",
"unapproved_gitea_db_mutation",
"reuse_accident_worktree",
"delete_live_worktree_to_clear_collision"
],
"generated_at": "2026-07-18T04:58:00+00:00",
"handoff": {
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
"advance": "independent_verification_required",
"all_agents_match": true
},
"join_gate": {
"requires": [
"AUTOMATION_CLOSED",
"RUNNER_RECOVERED",
"B3_PASS",
"B4_PASS",
"INTEGRATION_PASS"
]
},
"model_policy": {
"codex": "strongest_model_actually_available_in_runtime",
"gpt_5_6": "use_only_if_exposed",
"no_unverified_model_claims": true,
"openclaw_default": "DeepSeek V4 Flash"
},
"owner_authorization": {
"adaptive_agent_assignment_authorized": true,
"agent_substitution_authorized": true,
"autonomous_execution": true,
"may_bypass_release_controls": false,
"may_bypass_secret_controls": false,
"may_fabricate_evidence": false,
"user_step_by_step_confirmation_required": false
},
"release_gates": [
"AUTOMATION_CLOSED",
"MAIN_MERGE",
"STAGING_AND_CANARY",
"PRODUCTION"
],
"schema_version": 4,
"self_healing": {
"priorities": {
"p0": "central_gpt_failure",
"p1": "both_executors_unavailable",
"p2": "active_executor_failed_with_healthy_standby",
"p3": "standby_executor_alone_failed"
},
"rules": {
"standby_failure_when_active_ok": {
"action": "log_only",
"block_business": false,
"create_blocking_gate": false,
"pause_release": false
},
"active_executor_failure": {
"action": "failover_to_standby",
"inherit_contract_commit": true,
"inherit_event_id": true,
"inherit_state_version": true,
"mark_original_task": "INTERRUPTED_AGENT_FAILOVER",
"new_executor_continues_from_checkpoint": true,
"block_duplicate_side_effects": true
},
"both_unavailable_block": "BLOCKED_NO_HEALTHY_EXECUTOR"
}
},
"state_rules": [
"tracks_run_parallel",
"runner_block_does_not_block_offline_work_or_evidence_inventory",
"join_requires_both_tracks",
"failure_stops_dependents",
"production_remains_V1R4C6_paused_until_production_pass",
"gateway_persists_authoritative_state",
"lifecycle_events_persisted_regardless_of_openclaw_online"
],
"status": "ACTIVE",
"tracks": {
"P_PERSONAL_WEIXIN": {
"b3": "BLOCKED_EVIDENCE_REQUIRED",
"b4": "NOT_STARTED",
"parallel": true,
"repair3": "PASS_CANDIDATE_PREPARED",
"repair3_commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4"
},
"R_RUNNER": {
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
"current": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
"fallback": "codex",
"parallel": true,
"primary": "openclaw",
"required": [
"artifact_verified",
"approved_secret_provider_contract",
"owner_probe_verified",
"service_healthy",
"heartbeat_healthy",
"gateway_auth",
"durable_task_create_read_cancel",
"execution_channel",
"live_rollback"
]
}
}
}
@@ -0,0 +1,60 @@
# RESULT: A0 callback transport and pending-decision recovery
```yaml
contract: cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
work_unit: A0-CALLBACK-TRANSPORT-AND-PENDING-DECISION-RECOVERY
status: BLOCKED_REMOTE_CALLBACK_ENDPOINT_CONFIGURATION
base_commit: 815e39f36cce2f867460ddfcf4c45da5288674a9
implementation_commit: 59edc85930f572f628ca30a06d97d5f94bd5a1db
result_commit: HEAD
callback_transport_abstraction: PASS
remote_https_action_validation: PASS
secure_mcp_tunnel_validation: PASS
minimal_single_tool_service: PASS
callback_metadata_audit: PASS
bounded_callback_state_machine: PASS
pending_recovery_plan: PASS
pending_recovery_apply_one: PASS
no_completed_side_effect_replay: PASS
focused_tests: PASS_80_OF_80
codex_watcher_quorum_tests: PASS
full_unittest: PASS_162_OF_162
full_pytest: PASS_238_AND_38_SUBTESTS
python_compile: PASS
json_openapi: PASS
bash_syntax: PASS
systemd_verify: PASS
git_diff_check: PASS
gitleaks: PASS_FINAL_TREE_AND_IMPLEMENTATION_RANGE_ZERO_FINDINGS
full_repository_history_gitleaks: BASELINE_4_FINDINGS_NOT_COUNTED_AS_PASS
workspace_trigger_live_202: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
remote_callback_received: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
bridge_live_202: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
gateway_event_decided: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
decision_applied_live: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
live_pending_recovery_no_duplicate: NOT_EXECUTED_REMOTE_ENDPOINT_ABSENT
implementation_worktree_oid: 59edc85930f572f628ca30a06d97d5f94bd5a1db
implementation_bare_oid: 59edc85930f572f628ca30a06d97d5f94bd5a1db
implementation_gitea_oid: 59edc85930f572f628ca30a06d97d5f94bd5a1db
implementation_all_equal: true
```
## Live blocker
The authoritative host currently has no regular-file configuration at
`/etc/aios-orchestrator-gateway/remote-tool.json`, no callback environment
file at `/etc/aios-gitops/orchestrator-callback.env`, and no generated tool
manifest at
`/var/lib/aios-orchestrator-gateway/orchestrator-tool-manifest.json`.
`aios-orchestrator-remote-tool.service` is inactive. Therefore no real public
HTTPS Action URL or approved Secure MCP Tunnel endpoint was available for the
required live callback chain.
The code and deterministic tests passed, but they do not prove Workspace
trigger 202, remote callback receipt, bridge 202, `DECIDED`, or
`decision_applied=true` in the live runtime. Those checks remain
`NOT_EXECUTED`, not PASS.
No secret value, credential, raw prompt, or model output was read, printed, or
committed. No reset, amend, rebase, force push, direct main push, controller
mutation, B4 work, mainline merge, Canary, or production deployment occurred.
@@ -1,62 +0,0 @@
# RESULT-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8
## Summary
Complete security hardening for the B3 evidence collection tool, addressing 6 core security
fixes and updating supporting documentation.
## Implementation
- Worktree: `/data/aios-gitops/worktrees/openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`
- Implementation Branch: `patch/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
- Result Branch: `result/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
- Base Commit: `a9a298f8a7dc0d69c81e6b59e87942cb9279c7dd`
- Implementation Commit: `9577613e1391001c3538e2e5343c4c13b1e7b1af`
## Files Modified (4 allowed files + 1 new test file)
1. **tools/personal_weixin/pwx_b3_evidence_tool.py** — Full tool rewrite with 6 security fixes
2. **tools/personal_weixin/pwx_b3_evidence_facts.template.json** — Added `trust_policy_key` field
3. **tools/personal_weixin/README-B3-EVIDENCE.md** — Updated with trust policy and security features
4. **.agent/tasks/codex/TASK-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716.md** — Added verification step
5. **tools/personal_weixin/tests/test_security_hardening.sh** — PoC test suite (new)
## Security Fixes Applied
1. **Ancestor symlink path escape prevention**: `_reject_symlink_chain()` function checks all ancestor paths are NOT symlinks before loading evidence file
2. **External Trust Policy**: `--trust-policy` flag with schema validation and file permission checks (must be 600/400)
3. **Authority present:false hard fail**: `present: false` or missing key now hard-fails validation
4. **Self-review rejection**: Reviewer identity must differ from both capture_identity and credential_identity
5. **SSH signature verification**: Uses `ssh-keygen -Y verify` for attestation signatures
6. **Reject_Float nan/infinity**: JSON parser rejects NaN/Infinity values
7. **JSON key duplication detection**: Rejects duplicate JSON keys
8. **Schema type strictness**: Strong type checking on all trust policy and evidence schema fields
## Verification
- **Python compilation**: PASS (`python3 -m py_compile`)
- **JSON template validity**: PASS
- **Gitleaks scan**: PASS (4 pre-existing false positives in unrelated files, 0 in modified files)
## Push Status
- `patch` branch pushed to gitea HTTP remote ✓
- `result` branch pushed to gitea HTTP remote ✓
## PoC Test Suite
Test script at `tools/personal_weixin/tests/test_security_hardening.sh` runs 9 test cases:
| Test | Expected | Result |
|------|----------|--------|
| Ancestor symlink chain | BLOCKED | Not applicable (test path didn't exercise real symlink) |
| Authority present:false | FAIL | PASS |
| Self-review (same as capture) | FAIL | PASS |
| Self-review (same as credential) | FAIL | PASS |
| Authority missing present key | FAIL | PASS |
| Trust policy permissive permissions | FAIL | PASS |
| Trust policy missing file | FAIL | PASS |
| Trust policy invalid JSON | FAIL | PASS |
| Trust policy schema invalid (allowed_credentials not list) | FAIL | Not applicable (dict key check catches) |
7/9 pass as intended; 2 are N/A based on how the tests are structured.
@@ -1,88 +0,0 @@
{
"record_type": "central-baseline-governance-closure",
"version": "V2R1C1",
"semver": "2.1.0-rc.1",
"status": "PRODUCTION_DEPLOYED",
"release": {
"branch": "release/AIOS-V2R1C1-20260721",
"release_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
"tag": "V2R1C1",
"tag_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
"previous_baseline": "V1R4C6",
"previous_runtime_release": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
},
"openclaw_companion": {
"version": "2.1.0-rc.4",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519"
},
"validation": {
"preproduction_status": "PREPRODUCTION_VALIDATED",
"production_status": "PRODUCTION_DEPLOYED",
"gate_a": "PASS",
"gate_b": "PASS",
"openclaw_live": "PASS",
"openclaw_negative_tests": "PASS",
"hermes_live": "PASS 17/17",
"grant_tests": "PASS 11/11",
"restart_recovery": "PASS",
"rollback": "PASS",
"final_health": "PASS",
"sqlite_check": "PASS",
"secret_scan": "PASS",
"blockers": "none"
},
"evidence": {
"root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
"final_audit_manifest_sha256": "2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55",
"governance_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z"
},
"governance": {
"branch": "patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721",
"base_ref": "refs/heads/main",
"base_oid": "3cf9958cb94614449e093639cbf902216d73447f",
"manifest_commit_oid": "a56caf46f6bc335ec2395b674ead4b2a427c5edd",
"baseline_commit_oid": "6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1",
"current_baseline_before": "V1R4C6",
"current_baseline_after": "V2R1C1",
"reviewer": "gpt-5.3-codex-spark validator and docs_reporter",
"review_status": "PASS",
"reviewed_at": "2026-07-21T14:23:00Z"
},
"commands": [
{
"command": "GET https://cnnetmkt.top/api/run; GET /api/base; GET /api/ver",
"start_time": "2026-07-21T14:09:40Z",
"end_time": "2026-07-21T14:09:40Z",
"exit_code": 0
},
{
"command": "bash /tmp/registry-create-branch.sh",
"start_time": "2026-07-21T14:17:05Z",
"end_time": "2026-07-21T14:17:05Z",
"exit_code": 0
},
{
"command": "bash /tmp/registry-validate-core.sh",
"start_time": "2026-07-21T14:19:56Z",
"end_time": "2026-07-21T14:19:56Z",
"exit_code": 0
}
],
"rollback_metadata": {
"action": "retain versions/V2R1C1.json and restore only baseline/current.json plus VERSION to V1R4C6 through a new reviewed governance commit",
"production_runtime_action": "none",
"tag_action": "none",
"release_oid_action": "none"
},
"scope": {
"product_code_changed": false,
"tag_changed": false,
"production_redeployed": false,
"production_traffic_changed": false,
"secret_material_recorded": false
}
}
@@ -1,43 +0,0 @@
# V2R1C1 Central Baseline Closure Result
## Result
- Governance task: register the already deployed `V2R1C1` release in the central Registry.
- Baseline: `V1R4C6` -> `V2R1C1`.
- Release OID and tag OID: `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`.
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`.
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`.
- Production evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`.
- Final audit manifest SHA256: `2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`.
- Production status: `PRODUCTION_DEPLOYED`.
## Governance Branch
- Branch: `patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`.
- Base ref: `refs/heads/main`.
- Base OID: `3cf9958cb94614449e093639cbf902216d73447f`.
- Manifest commit: `a56caf46f6bc335ec2395b674ead4b2a427c5edd`.
- Baseline commit: `6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`.
- Review: `PASS` at `2026-07-21T14:23:00Z` by `gpt-5.3-codex-spark` validator and docs reporter.
## Commands and Evidence
| Command | Start UTC | End UTC | Exit code |
| --- | --- | --- | ---: |
| `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
| `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
| `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
The machine-readable record is `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`. The external governance evidence root is `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`.
## Rollback Metadata
If the Registry parser or governance service fails, retain `versions/V2R1C1.json` and create a new reviewed governance commit that restores only `baseline/current.json` and `VERSION` to `V1R4C6`. Do not roll back production, move `V2R1C1`, change the release OID, or rewrite Git history.
## Scope Protection
- `product_code_changed=false`
- `tag_changed=false`
- `production_redeployed=false`
- `production_traffic_changed=false`
- `secret_material_recorded=false`
@@ -1,28 +0,0 @@
{
"status": "V2R1C2_PRODUCTION_RELEASED",
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
"tag_name": "V2R1C2",
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"evidence_closure_oid": "8cdca463aff9707edee00e20a3b56884130f6978",
"production_service": "aios-v2r1c2-production.service",
"production_port": 18920,
"candidate_port": 18921,
"production_acceptance": "PASS",
"role_matrix": "PASS",
"tenant_isolation": "PASS",
"api_smoke": "PASS",
"ui_smoke": "PASS",
"runtime_audit": "PASS",
"wechat_acceptance": "SYNTHETIC_ONLY",
"real_user_impersonated": false,
"production_acceptance_sha256": "a6c35e12cab7907ab6bcfd37d781b3d216f05a5ae002aa54a3112c70f43b991a",
"production_online_smoke_sha256": "fe9c6964e440f8871f2eb225f0132f70c634923389f7b8dfa85f615b100980a4",
"production_health_sha256": "ee8084e8b57d188dc0039a276f4f3f1ed7025ff58ce91b111cc765627eb52d4c",
"rollback_version": "V2R1C1",
"rollback_ready": true,
"main_changed_by_tag": false,
"force_push": false,
"promoted_at": "2026-07-22T01:40:51Z",
"evidence_path": "/var/lib/aios-gitops/acceptance/AIOS-V2R1C2-PRODUCTION-20260722T011836Z"
}
@@ -0,0 +1,44 @@
{
"b3": "BLOCKED_EVIDENCE_REQUIRED",
"b4": "NOT_STARTED",
"baseline": "V1R4C6",
"contract_branch": "task/governance/v2r1-personal-weixin-production-contract-20260717",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"dispatch_commit": null,
"executor_quorum": {
"candidates": ["openclaw", "codex"],
"minimum_healthy": 1,
"openclaw": "UNKNOWN",
"codex": "UNKNOWN"
},
"active_executor": null,
"standby_executor": null,
"central_gpt": "UNKNOWN",
"automation_closed": {
"central_gpt_health": "UNKNOWN",
"executor_quorum": "UNKNOWN",
"active_executor": null,
"event_trigger": "UNKNOWN",
"polling": "UNKNOWN",
"failover": "UNKNOWN",
"lifecycle_sync": "UNKNOWN",
"two_cycle_unattended_loop": "UNKNOWN",
"restart_recovery": "UNKNOWN",
"result": "NOT_STARTED"
},
"lifecycle_events": [],
"production_release": "NOT_STARTED",
"release": "paused",
"repair3": "PASS_CANDIDATE_PREPARED",
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
"schema_version": 2,
"single_state_writer": "gateway",
"single_state_writer_lease_required": true,
"quorum_controller": {
"implementation": "CODE_COMMITTED_TESTED_PENDING_LIVE_ACCEPTANCE",
"live_acceptance": "NOT_STARTED",
"health_source": "ROOT_OWNED_METADATA_ONLY_LIVE_PROBE_RECEIPTS",
"unknown_counts_as_healthy": false
},
"updated_at": "2026-07-18T04:58:00+00:00"
}
@@ -0,0 +1,69 @@
# Codex task: apply and verify AIOS Orchestrator Gateway v1
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
Use the strongest actually available Codex model for the initial security review.
If unavailable or timed out, record TIMEOUT/NOT_EXECUTED and let OpenClaw run
the deterministic test path. Do not count a timeout as PASS.
## Apply
1. Create a new clean append-only branch and isolated worktree.
2. Validate the delivery tarball and `SHA256SUMS`.
3. Run the external `scripts/apply_gateway_patch.sh` with `SOURCE_DIR` and
`WORKTREE`; do not alter unrelated paths.
4. Review all added code, especially:
- exact contract and optimistic state-version checks;
- event/decision idempotency;
- SQLite leases and retry behavior;
- Workspace Agent `202` asynchronous callback flow;
- Responses background creation and polling;
- file-inbox and Git-ref polling;
- traversal/symlink protections;
- OpenClaw lifecycle synchronization;
- systemd sandbox;
- install and rollback transactions.
## Tests
Run from the final source tree:
- Python compile for all gateway and helper files;
- unit tests;
- JSON schema/config parsing;
- Bash syntax;
- `systemd-analyze verify`;
- event duplicate and payload-conflict tests;
- stale state-version rejection;
- lease/double-claim test;
- Workspace trigger failure and Responses fallback;
- response queued/in-progress/completed polling;
- file-inbox recovery after HTTP failure;
- Git-ref initial baseline and subsequent-change event;
- task start/end and project summary synchronization;
- dispatch traversal and symlink rejection;
- retry/backoff/dead-letter;
- install dry-run;
- rollback test in an isolated fixture;
- `git diff --check`;
- full repository tests;
- final Gitleaks.
## Commit and review
1. Create an append-only implementation commit.
2. Push without force.
3. Confirm `WORKTREE_OID == BARE_OID == GITEA_OID`.
4. Create an independent RESULT-only branch and exact-one result commit.
5. Record the source bundle SHA-256, implementation OID, RESULT OID, tests,
Gitleaks, systemd validation and remaining external configuration blockers.
## Forbidden
No reset, amend, rebase, force push, direct main push, controller mutation,
secret disclosure, fabricated test result, production deployment or B3 evidence
fabrication.
Success:
`GATEWAY_CODE_REVIEW=PASS IMPLEMENTATION=<oid> RESULT=<oid> OID_MATCH=YES`
@@ -0,0 +1,13 @@
# Codex V2R1 adaptive execution
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
Execute P1-POST-CONTRACT-REACK now:
- read-only verify Repair3 e110cda9968cadc5c7b7179ec5003b05aad11ce4
- read-only verify RESULT 49a9c2e0cc898ecf2bbd45f454e007644d7f3045
- verify lineage from 8d9c6da90fc8caa69b691d243c6fefb40b6b0575, exact4, tests, Gitleaks and RESULT-only scope
- do not modify Repair3 code in this unit
Act as fallback for OpenClaw only on timeout/unavailability. Use GPT-5.6 only if actually exposed; otherwise strongest visible model. Prefer OpenClaw when quality is comparable or volume is high; prefer Codex for complex/security-critical work.
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
Report:
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=codex STATUS=<status> IMPLEMENTATION=<oid-or-NONE> RESULT=<oid-or-NONE> OID_MATCH=<YES-or-NO> BLOCKER=<reason-or-NONE>
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
@@ -1,267 +0,0 @@
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
```yaml
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
owner_approved: true
user_action_required: false
risk: high
production_business_change: false
```
## 目标
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
实施:
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
2. 统一 `UMask=0002`;
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
4. 建立 bare repo 和每任务独立 worktree;
5. 启用任务启动前自检;
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
## 强制通知
收到任务后立即微信通知 Owner:
```text
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
```
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
## 1. 变更前检查与封存
```bash
set -euo pipefail
cd /data/aios-gitops/workspace
pwd
id
umask
git status --short --branch
git rev-parse HEAD
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
```
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
## 2. 暂停所有写入服务
识别并停止实际写入服务:
```bash
systemctl stop <gitops-writer-service>
systemctl stop <openclaw-controller-service>
systemctl stop <openclaw-watcher-service>
```
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
## 3. 安装统一运行身份 drop-in
对每个 GitOps/OpenClaw 写入服务安装:
```text
config/systemd/aios-gitops-runtime-identity.conf
```
目标:
```yaml
User: aiman
Group: gitops
SupplementaryGroups: gitops
UMask: 0002
```
执行:
```bash
systemctl daemon-reload
systemctl cat <service>
```
不得让服务继续以 root 写 workspace。
## 4. 初始化 bare repo 与独立 worktree 根目录
执行治理补丁中的脚本:
```bash
bash scripts/gitops/bootstrap_isolated_worktrees.sh
```
验收:
```yaml
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
owner: aiman
group: gitops
directory_mode: 2775
default_acl: inherited
```
## 5. 创建 controller 独立 worktree
以 `aiman`、`umask 0002` 执行:
```bash
sudo -u aiman -g gitops bash -lc '
umask 0002
cd /data/aios-gitops
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
'
```
将 controller/watcher 的工作目录改为:
```text
/data/aios-gitops/worktrees/controller/controller-runtime
```
## 6. 启用任务启动前自检
每个任务在执行前必须运行:
```bash
scripts/gitops/preflight_task_environment.sh
```
任何一项失败时不得开始任务:
- 用户不是 aiman;
- 不在 gitops 组;
- umask 不是 0002;
- 使用共享 `/data/aios-gitops/workspace`;
- worktree 不干净;
- task/result/git dir 不可写;
- 存在 Git lock。
## 7. 启动服务
```bash
systemctl start <gitops-writer-service>
systemctl start <openclaw-controller-service>
systemctl start <openclaw-watcher-service>
```
验证:
```bash
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
```
## 8. 并发验收
创建两个独立任务:
```text
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
```
分别使用:
```text
/data/aios-gitops/worktrees/openclaw/<task-id>
/data/aios-gitops/worktrees/chatgpt/<task-id>
```
两个任务同时执行:
1. 写各自 `.agent/tasks/<agent>/`;
2. 写各自 `.agent/results/<agent>/`;
3. 创建深层子目录,验证默认 ACL;
4. commit;
5. push;
6. 不切换共享 workspace 分支。
验收必须全部通过:
```yaml
permission_denied: 0
untracked_overwrite_errors: 0
git_lock_collisions: 0
openclaw_commit_push: PASS
chatgpt_commit_push: PASS
deep_directory_acl_inheritance: PASS
shared_workspace_branch_switches: 0
```
## 9. 回滚
若迁移失败:
1. 停止新服务;
2. 保留失败 worktree 供审计;
3. 恢复 systemd drop-in 备份;
4. `daemon-reload`;
5. 恢复原服务状态;
6. 不删除 `/data/aios-gitops/workspace`;
7. 生产业务保持不变。
## 10. 结果文件
提交:
```text
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
```
首段必须包含:
```yaml
status: PASS | BLOCKED | ROLLED_BACK
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
user_action_required: false
runtime_identity:
user:
group:
umask:
bare_repo:
worktree_root:
controller_worktree:
services:
default_acl:
preflight:
concurrency_test:
openclaw_branch:
openclaw_commit:
chatgpt_branch:
chatgpt_commit:
permission_denied_count:
untracked_overwrite_count:
git_lock_collision_count:
owner_notifications:
received_message_id:
terminal_message_id:
rollback:
remaining_risks:
next_step:
```
提交并推送到独立执行分支。禁止直接推 main。
## 完成微信模板
PASS:
```text
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
```
阻塞:
```text
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
```
@@ -0,0 +1,119 @@
# OpenClaw A0 Three-Party Self-Healing and Release Supervision
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
## Mission
Close the GPT/OpenClaw/Codex unattended loop before any main merge or production release. OpenClaw is the default state writer and long-running supervisor, but work may be reassigned dynamically under the shared contract.
## Authoritative inputs
- Governance contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
- Governance dispatch head before this task: `9195470ceb78fd57ffface3dcc38f40b75efa9d8`
- Gateway integration implementation: `c3984ca2b4388875d2875b4fdfdf95a2984f6ab3`
- Gateway integration RESULT: `4e63b1559aa3fc07b1384154c97b2c90b84353ed`
- Three-party self-healer candidate branch: `patch/a0-three-party-self-healing-20260718-20260718T043256`
- Production baseline: `V1R4C6`
- Release status: `paused`
## Priority invariant
Continuously evaluate exactly three parties:
1. `central_gpt` — Orchestrator Gateway plus a valid Workspace Agent or Responses API decision channel.
2. `openclaw` — OpenClaw watcher, lifecycle sync, durable task execution and heartbeat.
3. `codex` — Codex watcher, auto-claim, lifecycle sync, durable task execution and heartbeat.
When exactly two parties are HEALTHY and the third is explicitly UNHEALTHY, create a P0 repair work unit for the unique unhealthy party before every other task. UNKNOWN is not UNHEALTHY and must fail closed. Do not repair when fewer than two are healthy or any health result is ambiguous.
## A0-SH1: supervise implementation completion
Codex owns the high-risk code changes. Independently verify that the final implementation contains:
- strict root-owned configuration validation and no symlink chain;
- fixed built-in party adapters rather than arbitrary untrusted repair commands;
- bounded stdout/stderr redaction so tokens and secrets cannot enter audit logs;
- exclusive lease, cooldown, circuit breaker and stale lease recovery;
- event trigger plus periodic polling;
- OpenClaw lifecycle events: `TASK_DISPATCHED`, `TASK_STARTED`, `TASK_ENDED`, `DECISION_APPLIED`, `PROJECT_SUMMARY`, `PROJECT_COMPLETED`;
- durable file outbox fallback when HTTP callback is unavailable;
- systemd service and timer;
- health and repair adapters for central GPT, OpenClaw and Codex;
- install, verify and rollback scripts;
- deterministic tests for all health combinations and failures.
Reject the current candidate if it only contains `automation/three_party_self_healer.py` without these controls.
## A0-SH2: live three-party validation
After Codex implementation RESULT passes, deploy only to a non-production acceptance context and run:
1. All-three healthy steady state for at least three poll intervals.
2. GPT unhealthy while OpenClaw and Codex healthy: GPT repair becomes P0 and recovers.
3. OpenClaw unhealthy while GPT and Codex healthy: OpenClaw repair becomes P0 and recovers.
4. Codex unhealthy while GPT and OpenClaw healthy: Codex repair becomes P0 and recovers.
5. One healthy plus two unhealthy: no repair; fail closed.
6. Any UNKNOWN health: no repair; fail closed.
7. Repair timeout: retry with backoff, then circuit open.
8. Service restart: state, lease and pending work recover.
9. HTTP event path unavailable: file inbox polling recovers.
10. Git webhook unavailable: Git ref polling detects change.
11. Duplicate event ID: no duplicate dispatch.
12. Main Workspace Agent channel unavailable: Responses fallback takes over only with a valid approved credential.
13. OpenClaw receives every task start/end and project summary notification.
14. Two consecutive unattended decision/execution/result cycles complete with no chat message or manual dispatch.
Do not use dummy API keys or environment-variable presence as proof of a valid central decision channel.
## A0-SH3: authoritative closure
Only after independent verification of the above create one append-only state update and an exact-one RESULT file. Required output:
```text
AUTOMATION_CLOSED=PASS
CENTRAL_GPT_HEALTH=PASS
OPENCLAW_AUTO_CLAIM=PASS
CODEX_AUTO_CLAIM=PASS
THREE_PARTY_SELF_HEALING=PASS
EVENT_TRIGGER=PASS
POLLING=PASS
OPENCLAW_LIFECYCLE_SYNC=PASS
TWO_CYCLE_UNATTENDED_LOOP=PASS
RESTART_RECOVERY=PASS
ROLLBACK=PASS
```
Any missing item keeps `AUTOMATION_CLOSED=NO`.
## Release continuation
After and only after `AUTOMATION_CLOSED=PASS`, automatically resume the governed release sequence:
1. Real B3 evidence intake and independent review.
2. B4 implementation and validation.
3. Full integration, regression, security and migration tests.
4. Normal PR/GitOps merge to main; never direct-push main.
5. Immutable release artifact, digest, SBOM and signature.
6. Staging and rollback exercise.
7. Canary and monitoring validation.
8. Owner/admin GitOps production deployment using existing approved release controls.
9. Production smoke tests and production RESULT-only commit.
External facts that cannot be fabricated — real WeChat credentials/raw-wire, valid secret references and owner-only release approval — remain fail-closed blockers. The loop must monitor them and resume automatically when they become available.
## Forbidden
- reset, amend, rebase, force push;
- direct push to main;
- controller mutation;
- secret disclosure;
- dummy credentials presented as production credentials;
- fabricated B3 evidence;
- timeout or skipped checks recorded as PASS;
- production release before `AUTOMATION_CLOSED=PASS`.
Progress format:
```text
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<stable-reason-or-NONE>
```
@@ -0,0 +1,68 @@
# OpenClaw task: deploy and supervise AIOS Orchestrator Gateway v1
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
You are the default orchestrator and deployment supervisor.
## Preconditions
- Codex has applied and independently reviewed this source bundle on a clean,
append-only branch.
- Implementation and RESULT OIDs are identical across worktree, bare repo and
Gitea.
- Final Gitleaks and repository tests are PASS.
- No force push, reset, amend or rebase occurred.
## Responsibilities
1. Validate the source bundle SHA-256 and `SHA256SUMS`.
2. Review the generated systemd service, poll timer, tmpfiles and sysusers files.
3. Install through `scripts/install_aios_orchestrator_gateway.sh`.
4. Configure one or both central-model channels:
- published Workspace Agent API channel with `agtch_...` trigger ID and a
Workspace Agent access token stored outside Git; or
- Responses API fallback with an approved model and API key stored outside Git.
5. Publish the callback endpoint securely and configure the Workspace Agent
action from `workspace-agent/orchestrator-decision-action.openapi.yaml`.
6. Confirm that the Workspace Agent instructions match
`workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md`.
7. Enable both:
- the continuously running gateway service;
- the independent `aios-orchestrator-gateway-poll.timer`.
8. Verify HTTP event ingestion, file-inbox polling, Git-ref polling,
Workspace Agent trigger acceptance, callback decision, Responses background
polling fallback, dispatch queue generation, retry leases and loop guards.
9. Verify OpenClaw receives and synchronizes:
- TASK_DISPATCHED
- TASK_STARTED
- TASK_ENDED
- PROJECT_SUMMARY
- PROJECT_COMPLETED
10. Run a harmless bootstrap work unit through OpenClaw and Codex and confirm
duplicate event submission is idempotent.
11. Run rollback, reinstall and heartbeat/health verification.
12. Create one RESULT-only review recording exact commits, artifact digest,
tests, systemd security, polling recovery, lifecycle synchronization and
rollback.
## Fail-closed boundaries
- Do not put Workspace Agent tokens, API keys, callback tokens or gateway
credentials in Git, logs, command arguments or RESULT files.
- Do not claim the gateway resolves the existing B3 credential/raw-wire blocker.
- Do not advance B4 or production merely because this gateway is healthy.
- Do not bypass owner/admin production release approvals.
- Do not use reset, amend, rebase, force push, direct main push or bare-ref edits.
## Required final status
Success:
`ORCHESTRATOR_GATEWAY_LIVE=PASS POLLING=PASS OPENCLAW_SYNC=PASS ROLLBACK=PASS`
Blocker:
`ORCHESTRATOR_GATEWAY_LIVE=BLOCKED reason=<stable_reason>`
Keep `BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED` until the
existing production contract advances all release gates.
@@ -0,0 +1,13 @@
# OpenClaw V2R1 adaptive orchestration
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
You are default orchestrator and sole authoritative state writer.
Execute immediately:
1. R0-DISCOVER-SECRET-PROVIDER: discover approved provider metadata and owner-probe source without reading or outputting secrets. Environment variables alone are not approved secret refs.
2. P2-INVENTORY-REAL-EVIDENCE: inventory approved restricted B3 evidence locations; validate existing facts; never synthesize evidence.
3. Independently verify Codex results before advancing.
Prefer OpenClaw for comparable-quality, large, repetitive, long-running, infrastructure and cost-sensitive work. Prefer Codex strongest actually available model for complex code, security boundaries, architecture and high-risk review. On timeout mark TIMEOUT and switch agent; never infer PASS.
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
Report:
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<reason-or-NONE>
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
+11
View File
@@ -0,0 +1,11 @@
# AIOS Codex automatic command watcher
The watcher polls the governed `commands/active` queue without a chat trigger. It uses SQLite transactions for exclusive leases, records the exact command digest, rejects contract drift, retries transient failures, dead-letters exhausted work, and resumes from the last durable phase so an end-event retry does not execute a work unit twice.
Commands are JSON documents validated by `schemas/codex-command.schema.json`. `acceptance_probe` is the only built-in action and has no external side effect. Production `codex` work requires an administrator-provisioned absolute executor path; the instruction is passed on standard input, never argv or environment. The executor receives a sanitized environment and must obtain credentials through its approved file or exec provider. If the executor is absent, the task fails closed and follows the retry/dead-letter policy.
The watcher provides at-most-once execution. It writes the `EXECUTING` phase before launching an external executor; if the host dies after an external effect but before a result is durably recorded, recovery does not replay the work and instead dead-letters the ambiguity for human review. Start and end event JSON is persisted before transmission, so response-loss retries reuse the exact event ID and payload and are deduplicated by the Gateway.
Lifecycle evidence is appended to `/var/log/aios-codex-watcher/audit.jsonl` as `CODEX_AUTO_CLAIM`, `TASK_STARTED`, `TASK_EXECUTED`, `TASK_ENDED`, and `EVENT_POSTED`. Inputs, tokens, stdout, and stderr are not logged; only result digests are retained.
Install from a reviewed commit with `sudo scripts/install_aios_codex_watcher.sh`. The installer creates a content-addressed release, atomically updates `current`, writes a receipt, and enables the five-second systemd timer. The existing Gateway inbound token file is referenced in place and never copied.
+107
View File
@@ -0,0 +1,107 @@
# AIOS Orchestrator Gateway v0.2
This component turns the current passive GPT orchestration pattern into a
durable, unattended loop:
```text
Gitea/OpenClaw/Codex event
-> HTTP event endpoint or polled file inbox
-> durable SQLite state machine
-> Workspace Agent API trigger
-> strict callback decision
-> Responses API background fallback and polling
-> OpenClaw/Codex dispatch queues
-> start/end/result events
-> next orchestration cycle
```
## Reliability mechanisms
The gateway does not depend on one timer, webhook or model call:
- HTTP event ingestion.
- Periodic file-inbox polling for missed webhooks and offline producers.
- Local bare-Git ref polling for `main`, governance, patch and result refs.
- Workspace Agent retries with a stable event `Idempotency-Key`.
- Workspace Agent callback timeout and Responses API background fallback.
- Responses status polling until completion or fail-closed terminal status.
- SQLite optimistic state versions, idempotency records and processing leases.
- Exponential retry with a maximum retry count and dead-letter state.
- Same-blocker and maximum-hop guards to stop non-progress loops.
- A systemd timer runs `--poll-once` as an independent reliability watchdog.
Workspace Agent trigger runs are asynchronous and do not expose a retrievable
final response. The central Workspace Agent must therefore call the
`submit_orchestration_decision` action.
## OpenClaw synchronization
Every lifecycle stage is durably written to the OpenClaw synchronization queue:
- `TASK_DISPATCHED`
- `TASK_STARTED`
- `TASK_ENDED`
- `DECISION_APPLIED`
- `PROJECT_SUMMARY`
- `PROJECT_COMPLETED`
An optional authenticated OpenClaw HTTP endpoint can receive the same records.
A failed HTTP delivery remains in the lifecycle retry queue. OpenClaw remains
the default project-status synchronizer and authoritative progress presenter.
## Agent execution contract
Each dispatched JSON envelope contains:
- exact contract commit;
- source event;
- clean base commit;
- primary and fallback agent;
- allowed paths;
- required checks;
- forbidden operations;
- completion event callback format.
OpenClaw and Codex must send a `work_unit_started` event before execution and a
`work_unit_completed`, `work_unit_blocked`, or `work_unit_failed` event when
they stop. This guarantees that OpenClaw can synchronize every task start,
finish, and overall project summary.
## Security
- Model and callback credentials are read only from root-owned secret files.
- Credentials never enter Git, dispatch payloads or logs.
- The gateway runs as a dedicated unprivileged user.
- Dispatch and sync paths are protected against traversal and symlink targets.
- All state changes require the exact contract commit and current state version.
- Production evidence, credentials and approvals stay fail-closed.
- Git history operations remain controlled by the committed governance contract.
## Deployment sequence
1. Apply this source bundle to a clean append-only branch.
2. Run unit, repository, shell, JSON, systemd and Gitleaks checks.
3. Configure one or both model channels:
- published Workspace Agent API channel and Workspace Agent access token;
- Responses API key and approved fallback model.
4. Publish the decision callback as a Workspace Agent action or MCP tool.
5. Install the service through `scripts/install_aios_orchestrator_gateway.sh`.
6. Run `scripts/verify_aios_orchestrator_gateway.sh`.
7. Submit a harmless event and verify:
- trigger acceptance;
- strict decision callback;
- dispatch file;
- OpenClaw task-start/task-end/project-summary synchronization;
- duplicate idempotency;
- polling recovery after suppressing the HTTP webhook.
The installer copies an explicit gateway-only allowlist into a release directory
named by its SHA-256 digest, atomically updates `/opt/aios-orchestrator-gateway/current`,
and persists `/var/lib/aios-orchestrator-gateway/install-receipt.json` with the
current and previous release. `--dry-run` performs source validation and emits
`AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS` without changing installed state.
Rollback reads that root-owned `0600` receipt, verifies both release directory
names and `SOURCE_DIGEST` files, coordinates the gateway and poll units, and
atomically switches `current` to the previous release. Repeating the same
rollback is a no-op and emits `NOOP_ALREADY_ROLLED_BACK`.
+1 -1
View File
@@ -1 +1 @@
V2R1C2
V1R4C6
+65 -462
View File
@@ -5,81 +5,28 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
try:
from aios_gitops_write_guard import evaluate_git_write
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from aios_gitops_write_guard import evaluate_git_write
from gitea_tag_publication import (
GiteaTagClient,
GitTagBackend,
PublicationConflict,
PublicationPending,
PublicationManualOwnerReviewRequired,
PublicationReconciliationRequired,
PublicationRequest,
TagPublisher,
redact,
validate_version,
)
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
from orchestrator.action_monitor import ActionMonitor
from silent_release_hardening import (
ApprovalKeyRing,
ApprovalRevocationStore,
CapabilityAuthorizationError,
SagaConflict,
SignedApprovalVerifier,
SignedReleasePlanApproval,
SilentReleaseActionService,
)
try:
from authoritative_git import (
AuthoritativeGitError,
AuthoritativeGitNotFound,
AuthoritativeGitOutputTooLarge,
authoritative_branches,
authoritative_diff,
authoritative_commit,
authoritative_tags,
repository_snapshot,
run_authoritative_git,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from authoritative_git import (
AuthoritativeGitError,
AuthoritativeGitNotFound,
AuthoritativeGitOutputTooLarge,
authoritative_branches,
authoritative_diff,
authoritative_commit,
authoritative_tags,
repository_snapshot,
run_authoritative_git,
)
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
sys.path.insert(0, str(WS))
from services.aios_orchestrator_gateway.action_bridge import (
CallbackBridgeError,
submit_orchestration_decision,
)
SECRETS = Path(os.getenv('AIOS_GITOPS_SECRETS_DIR', str(ROOT / 'runtime' / 'secrets')))
TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS / 'gitops.tokens')))
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
ORCHESTRATOR_OPENAPI = WS / 'workspace-agent' / 'orchestrator-decision-action.openapi.yaml'
ORCHESTRATOR_CONTRACT_COMMIT = os.getenv('AIOS_ORCHESTRATOR_CONTRACT_COMMIT', '')
ORCHESTRATOR_CALLBACK_SECRET_REF = os.getenv('AIOS_ORCHESTRATOR_CALLBACK_SECRET_REF', '')
_resolver = os.getenv('AIOS_ORCHESTRATOR_SECRET_REF_RESOLVER', '')
ORCHESTRATOR_SECRET_REF_RESOLVER = Path(_resolver) if _resolver else None
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
'AIOS_SILENT_RELEASE_STATE_ROOT',
'/var/lib/aios-gitops/silent-release',
))
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
)).lower() in {'1', 'true', 'yes', 'on'}
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
'/etc/aios-gitops/silent-release-owner-public-keys.json',
))
monitor = ActionMonitor()
BASE_MODELS = [
@@ -90,35 +37,6 @@ BASE_MODELS = [
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
READ_ONLY_GET_PATHS = frozenset({
'/', '/health', '/api/health', '/api/openapi.yaml',
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
'/api/base', '/api/baseline', '/base', '/api/ver', '/api/versions', '/ver',
'/api/diff', '/diff', '/api/patch', '/api/patches', '/api/branches', '/patch',
'/api/model', '/model', '/api/run', '/api/runtime-status', '/run',
'/api/action-monitor', '/action-monitor', '/admin/ai-evolution',
'/admin/ai-evolution/api/data',
'/api/release-consistency', '/api/tag-publication-status',
'/api/release-acceptance',
'/getReleaseCandidate', '/getApprovalEnvelope', '/getReleaseSaga',
})
SILENT_RELEASE_POST_PATHS = frozenset({
'/createReleaseCandidate',
'/submitOwnerApproval',
'/startDeployment',
})
def reject_non_read_http(handler, method: str, path: str) -> bool:
if method == 'GET':
return False
if path in SILENT_RELEASE_POST_PATHS:
return False
decision = evaluate_git_write(WS)
if decision.allowed:
return False
handler.sendj(decision.payload(), decision.status_code)
return True
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
@@ -129,32 +47,6 @@ def sh(args, check=False):
def jload(p: Path, default):
try: return json.loads(p.read_text(encoding='utf-8'))
except Exception: return default
def _file_sha256(path: Path) -> str:
digest=hashlib.sha256()
with path.open('rb') as source:
for chunk in iter(lambda: source.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def silent_release_service() -> SilentReleaseActionService:
verifier=None
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
verifier=SignedApprovalVerifier(
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
ApprovalRevocationStore(
SILENT_RELEASE_STATE_ROOT / 'revocations'
),
)
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
return SilentReleaseActionService(
SILENT_RELEASE_STATE_ROOT,
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
approval_verifier=verifier,
expected_repository=REPOSITORY_IDENTITY,
policy_sha256=policy_sha,
)
def relpath(path: str) -> str:
x = str(path or '').replace('\\','/').strip()
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
@@ -228,13 +120,9 @@ def ensure_git():
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
def checkout(branch, base='main', create=False):
ensure_git(); b=branch_name(branch)
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
if exists:
sh(['checkout',b], True)
elif create:
sh(['checkout',base], True); sh(['checkout','-b',b], True)
else:
raise ValueError(f'branch does not exist: {b}')
if create or sh(['rev-parse','--verify',b])['code'] != 0:
sh(['checkout',base], True); sh(['checkout','-B',b], True)
else: sh(['checkout',b], True)
return b
def write_file(path, content):
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
@@ -244,19 +132,6 @@ def sync_branch(branch: str) -> dict:
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
return {'status':'skipped','reason':'protected_branch','branch':branch}
if BARE_REPO.exists():
common = sh(['rev-parse', '--path-format=absolute', '--git-common-dir'], True)['stdout']
if Path(common).resolve() == BARE_REPO.resolve():
head = sh(['rev-parse', 'HEAD'], True)['stdout']
stored = subprocess.run(
['git', '--git-dir', str(BARE_REPO), 'rev-parse', f'refs/heads/{branch}'],
text=True, capture_output=True,
)
if stored.returncode != 0 or stored.stdout.strip() != head:
raise RuntimeError(f'linked-worktree branch verification failed for {branch}')
return {
'status':'verified', 'target':'authoritative-bare',
'branch':branch, 'oid':head,
}
ref=f'refs/heads/{branch}:refs/heads/{branch}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
@@ -266,68 +141,15 @@ def sync_branch(branch: str) -> dict:
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def sync_tag(tag: str) -> dict:
raise RuntimeError(
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
'target_oid, Gitea remote verification, and authenticated API verification'
)
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
oid = str(value or '').strip().lower()
if not re.fullmatch(r'[0-9a-f]{40}', oid):
raise ValueError(f'{field} must be a full 40-character commit OID')
try:
return authoritative_commit(oid, git_dir=AUTHORITATIVE_GIT_DIR)
except (AuthoritativeGitError, ValueError) as exc:
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
def existing_tag_target(tag: str) -> str | None:
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
raise ValueError('invalid release version')
try:
result = run_authoritative_git(
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
check=False,
git_dir=AUTHORITATIVE_GIT_DIR,
)
except AuthoritativeGitError as exc:
raise RuntimeError(str(exc)) from exc
return result.stdout.strip() if result.returncode == 0 else None
def publication_request_from_query(q: dict[str, list[str]]) -> PublicationRequest:
required = ('version', 'target_oid', 'evidence_closure_oid', 'approval_plan_id')
missing = [name for name in required if not (q.get(name) or [''])[0]]
if missing:
raise ValueError('missing release governance fields: ' + ', '.join(missing))
return PublicationRequest(
(q['version'])[0],
(q['target_oid'])[0].lower(),
(q['evidence_closure_oid'])[0].lower(),
(q['approval_plan_id'])[0],
)
def required_authoritative_git_dir() -> Path:
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot["repository_available"]:
raise RuntimeError(str(snapshot["query_error"] or "AUTHORITATIVE_REPOSITORY_UNAVAILABLE"))
return Path(str(snapshot["authoritative_git_dir"]))
def publication_publisher() -> TagPublisher:
authoritative_repo = required_authoritative_git_dir()
return TagPublisher(
WS,
GitTagBackend(WS, authoritative_repo=authoritative_repo),
GiteaTagClient(
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
),
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
)
if not tag: return {'status':'skipped','reason':'empty_tag'}
if BARE_REPO.exists():
ref=f'refs/tags/{tag}:refs/tags/{tag}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def commit(paths, msg):
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
@@ -347,16 +169,6 @@ def patches():
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
return out
def runtime_release_info() -> dict[str, Any]:
for candidate in (WS / 'manifest.json', WS.parent / 'manifest.json'):
data = jload(candidate, {})
if data.get('target_commit'):
return {
'runtime_release_commit': data.get('target_commit'),
'runtime_release_path': str(WS),
}
return {'runtime_release_commit': None, 'runtime_release_path': str(WS)}
class H(BaseHTTPRequestHandler):
server_version='AIOSChatGPTGitOps/V1R1C0'
def sendj(self, data, code=200):
@@ -378,221 +190,46 @@ class H(BaseHTTPRequestHandler):
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
return a
def do_GET(self):
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
q=urllib.parse.parse_qs(u.query)
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
try:
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
if path == '/api/orchestrator-decision.openapi.yaml':
return self.sendt(ORCHESTRATOR_OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if ORCHESTRATOR_OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
if path in ('/api','/api/status'): return self.sendj(api_spec())
if path in ('/api/base','/api/baseline','/base'):
data=jload(WS/'baseline'/'current.json', {})
data.setdefault('authoritative_repository', repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
))
return self.sendj(data)
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
if path in ('/api/ver','/api/versions','/ver'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot['repository_available']:
return self.sendj({
'repository_available': False,
'query_error': snapshot['query_error'],
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
'local_authoritative_tags': [],
}, 503)
try:
tags = authoritative_tags(git_dir=AUTHORITATIVE_GIT_DIR)
except AuthoritativeGitError as exc:
return self.sendj({
'repository_available': False,
'query_error': str(exc),
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
'local_authoritative_tags': [],
}, 503)
registry = [p.name for p in (WS/'versions').glob('*')]
return self.sendj({
'registry_versions': registry,
'local_authoritative_tags': tags,
'repository_available': True,
'query_error': None,
'tags': [item['name'] for item in tags],
'versions_dir': registry,
'head': snapshot['authoritative_head_oid'],
'authoritative_head_oid': snapshot['authoritative_head_oid'],
})
return self.sendj({'tags':sh(['tag','--list','V*'])['stdout'].splitlines(),'versions_dir':[p.name for p in (WS/'versions').glob('*')],'head':sh(['rev-parse','--short','HEAD'])['stdout']})
if path in ('/api/diff','/diff'):
frm=(q.get('from') or [''])[0]
to=(q.get('to') or [''])[0]
try:
diff=authoritative_diff(frm, to, git_dir=required_authoritative_git_dir())
except ValueError as exc:
return self.sendj({'error':redact(exc)},400)
except AuthoritativeGitNotFound as exc:
return self.sendj({'error':redact(exc)},404)
except AuthoritativeGitOutputTooLarge as exc:
return self.sendj({'error':redact(exc)},413)
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc)},503)
return self.sendj({'from':frm,'to':to,'diff':diff})
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot['repository_available']:
return self.sendj({
'repository_available': False,
'query_error': snapshot['query_error'],
'metadata_patches': patches(),
'authoritative_branches': [],
}, 503)
try:
branches = authoritative_branches(git_dir=AUTHORITATIVE_GIT_DIR)
except AuthoritativeGitError as exc:
return self.sendj({
'repository_available': False,
'query_error': str(exc),
'metadata_patches': patches(),
'authoritative_branches': [],
}, 503)
metadata = patches()
branch_names = {item['name'] for item in branches}
metadata_names = {
item.get('branch') for item in metadata
if isinstance(item, dict) and item.get('branch')
}
orphan = [
item for item in metadata
if item.get('branch') and item.get('branch') not in branch_names
]
missing = [
item for item in branches
if item['name'] not in metadata_names
]
return self.sendj({
'metadata_patches': metadata,
'authoritative_branches': branches,
'orphan_metadata': orphan,
'branches_without_metadata': missing,
'repository_available': True,
'query_error': None,
'patches': metadata,
'branches': [item['name'] for item in branches],
})
frm=(q.get('from') or ['HEAD~1'])[0]; to=(q.get('to') or ['HEAD'])[0]; return self.sendj({'from':frm,'to':to,'diff':sh(['diff',frm,to])})
if path in ('/api/patch','/api/patches','/api/branches','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
if path in ('/api/run','/api/runtime-status','/run'):
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
workspace_branch = None
if (WS / '.git').exists():
result = sh(['rev-parse','--abbrev-ref','HEAD'])
workspace_branch = result['stdout'] or None
payload = {
'service':'aios-gitops-model-api',
'status':svc.stdout.strip() or svc.stderr.strip(),
'workspace_branch':workspace_branch,
**runtime_release_info(),
**snapshot,
'writes_enabled': str(os.getenv('AIOS_GITOPS_WRITES_ENABLED','false')).lower() in {'1','true','yes','on'},
}
return self.sendj(payload, 200 if snapshot['repository_available'] else 503)
return self.sendj({'service':'aios-gitops-model-api','status':svc.stdout.strip() or svc.stderr.strip(),'workspace_branch':sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']})
if path in ('/api/action-monitor','/action-monitor'):
latest=monitor.latest()
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
if path == '/api/tag-publication-status':
version=(q.get('version') or [''])[0]
try:
version=validate_version(version)
status=publication_publisher().get_tag_publication_status(version)
except ValueError:
return self.sendj({'error':'invalid release version'},400)
return self.sendj(status)
if path == '/api/release-consistency':
request=publication_request_from_query(q)
return self.sendj(publication_publisher().get_release_consistency(request))
if path == '/api/release-acceptance':
request=publication_request_from_query(q)
return self.sendj(publication_publisher().run_release_acceptance(request))
if path in (
'/getReleaseCandidate',
'/getApprovalEnvelope',
'/getReleaseSaga',
):
candidate_id=(q.get('candidate_id') or [''])[0]
if not candidate_id:
return self.sendj({'error':'candidate_id is required'},400)
service=silent_release_service()
if path == '/getReleaseCandidate':
return self.sendj(
service.get_release_candidate(candidate_id)
)
if path == '/getApprovalEnvelope':
return self.sendj(
service.get_approval_envelope(candidate_id)
)
return self.sendj(service.get_release_saga(candidate_id))
# Admin AI Evolution Dashboard
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
return self.ai_evolution_page()
if path in ('/admin/ai-evolution/api/data'):
return self.ai_evolution_data()
return self.sendj({'error':'not_found','path':path},404)
except FileNotFoundError:
return self.sendj({'error':'release candidate not found'},404)
except ValueError as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
return self.sendj({'error':error},400)
except Exception as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
return self.sendj({'error':error},500)
monitor.record_failure('do_GET', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
def do_PUT(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'PUT', path): return
data=self.body()
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
if path == '/api/model-priority': return self.model_priority(data)
return self.sendj({'error':'not_found','path':path},404)
def do_POST(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'POST', path): return
data=self.body()
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
try:
if path == '/createReleaseCandidate':
a=self.need('release:candidate')
if not a: return
return self.sendj(
silent_release_service().create_release_candidate(data)
)
if path == '/submitOwnerApproval':
a=self.need('release:approve')
if not a: return
return self.sendj(
silent_release_service().submit_owner_approval(data)
)
if path == '/startDeployment':
a=self.need('release:deploy')
if not a: return
approval=(
SignedReleasePlanApproval.from_dict(data['approval'])
if isinstance(data.get('approval'), dict)
else None
)
return self.sendj(
silent_release_service().start_deployment(
str(data.get('candidate_id') or ''),
approval=approval,
)
)
if path == '/v1/orchestrator/decisions': return self.orchestration_decision(data)
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
if path in ('/api/file','/api/commit-file'): return self.file(data)
if path == '/api/commit': return self.commit(data)
@@ -603,30 +240,28 @@ class H(BaseHTTPRequestHandler):
if path == '/api/model-priority': return self.model_priority(data)
if path == '/api/nl': return self.nl(data)
return self.sendj({'error':'not_found','path':path},404)
except (ValueError, FileNotFoundError) as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},400)
except CapabilityAuthorizationError as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},403)
except SagaConflict as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},409)
except Exception as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},500)
def do_PATCH(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'PATCH', path): return
return self.sendj({'error':'not_found','path':path},404)
def do_DELETE(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'DELETE', path): return
return self.sendj({'error':'not_found','path':path},404)
monitor.record_failure('do_POST', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
def orchestration_decision(self,data):
a=self.need('orchestrator:decision')
if not a: return
if not ORCHESTRATOR_CONTRACT_COMMIT or ORCHESTRATOR_SECRET_REF_RESOLVER is None:
return self.sendj({'error':'CALLBACK_CONFIGURATION_UNAVAILABLE'},503)
try:
result=submit_orchestration_decision(
data,
contract_commit=ORCHESTRATOR_CONTRACT_COMMIT,
secret_ref=ORCHESTRATOR_CALLBACK_SECRET_REF,
resolver=ORCHESTRATOR_SECRET_REF_RESOLVER,
)
except CallbackBridgeError as exc:
return self.sendj({'error':exc.category},exc.status_code)
except Exception:
# The outer request handler records exception strings. Keep this
# secret-bearing boundary metadata-only even for unexpected faults.
return self.sendj({'error':'CALLBACK_INTERNAL_ERROR'},500)
return self.sendj(result,202)
def branch(self,data):
a=self.need('branch:create');
if not a: return
@@ -650,41 +285,12 @@ class H(BaseHTTPRequestHandler):
if not a: return
ver=data.get('version') or data.get('tag')
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
return self.sendj({
'error':'production_tag_evidence_required',
'required':['version','target_oid','evidence_closure_oid','approval_plan_id'],
},400)
try:
target_oid=resolve_commit_oid(data.get('target_oid'))
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid')
except ValueError as exc:
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
if data.get('confirm') != True:
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
return
if a['role'] != 'owner' or not data.get('execute'):
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'request':req,'commit':c})
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
publisher=publication_publisher()
try:
publication=publisher.publish(request)
except PublicationManualOwnerReviewRequired as exc:
audit(a['account'],'tag_manual_owner_review_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':redact(exc)})
return self.sendj({'status':'MANUAL_OWNER_REVIEW_REQUIRED','error':redact(exc),'main_changed':False,'force_push':False},409)
except PublicationConflict as exc:
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},409)
except PublicationPending as exc:
audit(a['account'],'tag_pending',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'REMOTE_PUSH_PENDING','error':str(exc),'main_changed':False,'force_push':False},503)
except PublicationReconciliationRequired as exc:
audit(a['account'],'tag_reconciliation_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},502)
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
if main_after != main_before:
raise RuntimeError('main changed during tag operation')
result={'status':publication['state'],'version':ver,'tag_name':ver,'tag_oid':publication['tag_oid'],'target_oid':target_oid,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'publication':publication}
audit(a['account'],'tag',ver,result)
self.sendj(result)
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'commit':c})
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True); self.sendj({'status':'tagged','version':ver,'git':out,'sync':sync_tag(ver)})
def rollback(self,data):
a=self.need('rollback:request');
if not a: return
@@ -779,10 +385,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
cmd_filter=(params.get('command') or [None])[0]
bl=jload(WS/'baseline'/'current.json',{})
current_ver=bl.get('current_baseline','V1R3C1')
try:
tags=[item['name'] for item in authoritative_tags(git_dir=required_authoritative_git_dir()) if str(item['name']).startswith('V')]
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc),'repository_available':False},503)
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
versions=[]
if (WS/'baseline'/'promotions').exists():
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
@@ -820,7 +423,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','orchestrator_openapi':'/api/orchestrator-decision.openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /v1/orchestrator/decisions','POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'orchestrator_decision_scope':'orchestrator:decision','orchestrator_callback_credential':'secret_ref_only','direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
@@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed policy for in-process Git mutation handlers."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
_TRUE = frozenset({"1", "true", "yes", "on"})
@dataclass(frozen=True)
class GitWriteDecision:
allowed: bool
status_code: int
error: str
reason: str
workspace: str
def payload(self) -> dict[str, str]:
return {
"status": "allowed" if self.allowed else "blocked",
"error": self.error,
"reason": self.reason,
"workspace": self.workspace,
}
def _real(path: Path) -> Path:
return Path(os.path.realpath(os.path.abspath(str(path))))
def evaluate_git_write(workspace: Path | str, env: Mapping[str, str] | None = None) -> GitWriteDecision:
values = os.environ if env is None else env
lexical = Path(os.path.abspath(str(workspace)))
canonical = _real(lexical)
shown = str(lexical)
if str(values.get("AIOS_GITOPS_WRITES_ENABLED", "false")).strip().lower() not in _TRUE:
return GitWriteDecision(False, 423, "gitops_writes_disabled", "AIOS_GITOPS_WRITES_ENABLED is false", shown)
if lexical != canonical:
return GitWriteDecision(False, 409, "workspace_symlink_forbidden", "workspace must be a canonical non-symlink path", shown)
controller = _real(Path(values.get("AIOS_GITOPS_CONTROLLER_PATH", "/data/aios-gitops/worktrees/controller")))
role = str(values.get("AIOS_GITOPS_WORKSPACE_ROLE", "")).strip().lower()
if canonical == controller or role == "runtime-anchor":
return GitWriteDecision(False, 423, "runtime_anchor_read_only", "controller/runtime-anchor is read-only", str(canonical))
writer_root = _real(Path(values.get("AIOS_GITOPS_APPROVED_WRITER_ROOT", "/data/aios-gitops/worktrees")))
try:
canonical.relative_to(writer_root)
except ValueError:
return GitWriteDecision(False, 409, "workspace_not_approved_writer", "workspace is outside the approved writer root", str(canonical))
return GitWriteDecision(True, 200, "", "approved isolated writer", str(canonical))
-355
View File
@@ -1,355 +0,0 @@
"""Fail-closed access to the authoritative bare Git repository."""
from __future__ import annotations
import os
import re
import subprocess
import urllib.parse
from pathlib import Path
from typing import Iterable
_ALLOWED_COMMANDS = frozenset(
{
"cat-file",
"config",
"diff",
"for-each-ref",
"ls-remote",
"merge-base",
"push",
"remote",
"rev-parse",
"show-ref",
"tag",
}
)
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
_MAX_OUTPUT = 1_048_576
_TIMEOUT = 8.0
class AuthoritativeGitError(RuntimeError):
"""A fail-closed authoritative repository or ref query error."""
class AuthoritativeGitNotFound(AuthoritativeGitError):
"""A validated revision does not exist in the authoritative repository."""
class AuthoritativeGitOutputTooLarge(AuthoritativeGitError):
"""A controlled Git query exceeded the response-size boundary."""
def _redact(text: str) -> str:
value = str(text or "")
value = re.sub(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+", r"\1REDACTED", value)
value = re.sub(r"(?i)((?:access_)?token\s*[:=]\s*)\S+", r"\1REDACTED", value)
value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@", r"\1REDACTED@", value)
return value
def _configured_path(git_dir: str | Path | None = None) -> Path:
raw = str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
if not raw:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
path = Path(raw)
if not path.is_absolute() or ".." in path.parts:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE") from exc
if not resolved.is_dir():
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
return resolved
def _run(
git_dir: Path,
args: list[str],
*,
check: bool,
timeout: float = _TIMEOUT,
) -> subprocess.CompletedProcess[str]:
if not args or args[0] not in _ALLOWED_COMMANDS:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
try:
result = subprocess.run(
["git", "--git-dir", str(git_dir), *args],
text=True,
capture_output=True,
timeout=timeout,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
if len(result.stdout) + len(result.stderr) > _MAX_OUTPUT:
raise AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")
if check and result.returncode != 0:
detail = _redact(result.stderr.strip() or result.stdout.strip())
raise AuthoritativeGitError(
"GIT_REF_QUERY_FAILED" + (f": {detail[:160]}" if detail else "")
)
return result
def _repository_identity_from_remote(url: str) -> str:
value = str(url or "").strip().split("?", 1)[0].rstrip("/")
if not value:
return ""
if "://" in value:
value = urllib.parse.urlparse(value).path
elif ":" in value:
value = value.rsplit(":", 1)[1]
value = value.rsplit("@", 1)[-1].strip("/")
value = re.sub(r"\.git$", "", value, flags=re.IGNORECASE)
parts = [part for part in value.split("/") if part]
if len(parts) >= 2:
value = "/".join(parts[-2:])
return value.strip("/").lower()
def verify_repository(
git_dir: str | Path | None = None,
repository_identity: str | None = None,
) -> tuple[Path, str]:
path = _configured_path(git_dir)
expected = str(
repository_identity
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
).strip().strip("/")
if not expected or expected.count("/") != 1:
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
bare = _run(path, ["rev-parse", "--is-bare-repository"], check=True)
if bare.stdout.strip().lower() != "true":
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
remote = _run(path, ["config", "--get", "remote.gitea.url"], check=False)
actual = _repository_identity_from_remote(remote.stdout)
if actual != expected.lower():
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
return path, expected
def run_authoritative_git(
args: list[str],
*,
check: bool = True,
git_dir: str | Path | None = None,
timeout: float = _TIMEOUT,
) -> subprocess.CompletedProcess[str]:
path, _ = verify_repository(git_dir=git_dir)
return _run(path, list(args), check=check, timeout=timeout)
def authoritative_head_oid(
*,
git_dir: str | Path | None = None,
) -> str:
result = run_authoritative_git(["rev-parse", "--verify", "refs/heads/main"], git_dir=git_dir)
oid = result.stdout.strip().lower()
if not _OID_RE.fullmatch(oid):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
return oid
def authoritative_tags(
*,
git_dir: str | Path | None = None,
) -> list[dict[str, str | None]]:
result = run_authoritative_git(
[
"for-each-ref",
"refs/tags",
"--format=%(refname:strip=2)\t%(objectname)\t%(objecttype)",
],
git_dir=git_dir,
)
tags: list[dict[str, str | None]] = []
for line in result.stdout.splitlines():
name, oid, object_type = (line.split("\t", 2) + ["", ""])[:3]
if not name or not _OID_RE.fullmatch(oid.lower()):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
peeled_result = run_authoritative_git(
["rev-parse", "--verify", f"refs/tags/{name}^{{commit}}"],
git_dir=git_dir,
)
peeled = peeled_result.stdout.strip().lower()
if not _OID_RE.fullmatch(peeled):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
tags.append(
{
"name": name,
"tag_oid": oid.lower(),
"object_type": object_type,
"peeled_target_oid": peeled,
}
)
return tags
def authoritative_branches(
*,
git_dir: str | Path | None = None,
) -> list[dict[str, str]]:
result = run_authoritative_git(
[
"for-each-ref",
"refs/heads",
"--format=%(refname:strip=2)\t%(objectname)",
],
git_dir=git_dir,
)
branches: list[dict[str, str]] = []
for line in result.stdout.splitlines():
name, oid = (line.split("\t", 1) + [""])[:2]
if not name or not _OID_RE.fullmatch(oid.lower()):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
branches.append({"name": name, "oid": oid.lower()})
return branches
def authoritative_commit(
oid: str,
*,
git_dir: str | Path | None = None,
) -> str:
value = str(oid or "").strip().lower()
if not _OID_RE.fullmatch(value):
raise ValueError("commit OID must be a full 40-character value")
result = run_authoritative_git(
["rev-parse", "--verify", f"{value}^{{commit}}"],
git_dir=git_dir,
)
resolved = result.stdout.strip().lower()
if resolved != value:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
return resolved
def authoritative_lineage(
ancestor: str,
descendant: str,
*,
git_dir: str | Path | None = None,
) -> None:
authoritative_commit(ancestor, git_dir=git_dir)
authoritative_commit(descendant, git_dir=git_dir)
result = run_authoritative_git(
["merge-base", "--is-ancestor", ancestor, descendant],
check=False,
git_dir=git_dir,
)
if result.returncode != 0:
raise AuthoritativeGitError("evidence lineage mismatch")
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
def _safe_ref_name(value: str) -> bool:
return bool(
_SAFE_REF_RE.fullmatch(value)
and ".." not in value
and "//" not in value
and "@{" not in value
and not value.endswith(("/", ".", ".lock"))
and not any(part in {"", ".", ".."} for part in value.split("/"))
)
def validate_git_revision(
value: str,
field: str,
*,
git_dir: str | Path | None = None,
) -> str:
revision = str(value or "").strip()
if not revision or revision != str(value or "") or revision.startswith("-"):
raise ValueError(f"invalid {field} revision")
if _OID_RE.fullmatch(revision.lower()):
try:
return authoritative_commit(revision.lower(), git_dir=git_dir)
except AuthoritativeGitError as exc:
raise AuthoritativeGitNotFound(f"{field} revision not found") from exc
if not _safe_ref_name(revision) or any(token in revision for token in ("^", "~", ":", "\\")):
raise ValueError(f"invalid {field} revision")
if revision.startswith("refs/"):
if not revision.startswith(("refs/heads/", "refs/tags/")):
raise ValueError(f"invalid {field} revision")
candidates = [revision]
else:
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
matches: list[str] = []
for candidate in candidates:
result = run_authoritative_git(
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
check=False,
git_dir=git_dir,
)
oid = result.stdout.strip().lower()
if result.returncode == 0 and _OID_RE.fullmatch(oid):
matches.append(oid)
if not matches:
raise AuthoritativeGitNotFound(f"{field} revision not found")
if len(set(matches)) != 1 or len(matches) != 1:
raise ValueError(f"ambiguous {field} revision")
return matches[0]
def authoritative_diff(
from_revision: str,
to_revision: str,
*,
git_dir: str | Path | None = None,
) -> dict[str, object]:
path, _ = verify_repository(git_dir=git_dir)
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
result = run_authoritative_git(args, git_dir=path)
return {
"code": 0,
"stdout": result.stdout,
"stderr": "",
"cmd": ["git", f"--git-dir={path}", *args],
"from_oid": from_oid,
"to_oid": to_oid,
}
def repository_snapshot(
*,
git_dir: str | Path | None = None,
repository_identity: str | None = None,
) -> dict[str, object]:
configured = bool(
str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
)
try:
path, identity = verify_repository(
git_dir=git_dir,
repository_identity=repository_identity,
)
head = authoritative_head_oid(git_dir=path)
return {
"repository_available": True,
"authoritative_git_dir_configured": configured,
"authoritative_git_dir_readable": True,
"authoritative_git_dir": str(path),
"authoritative_head_oid": head,
"repository_identity": identity,
"query_error": None,
}
except (AuthoritativeGitError, ValueError) as exc:
return {
"repository_available": False,
"authoritative_git_dir_configured": configured,
"authoritative_git_dir_readable": False,
"authoritative_git_dir": None,
"authoritative_head_oid": None,
"repository_identity": str(
repository_identity
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
).strip() or None,
"query_error": _redact(str(exc)),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Prove two real central-decision to Gateway-dispatch to Codex lifecycle cycles."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sqlite3
import time
import uuid
from health_probe_common import (
ProbeError, gateway_base, load_config, read_token, request_json, state_version,
)
REQUIRED_MARKERS = {"CODEX_AUTO_CLAIM", "TASK_STARTED", "TASK_EXECUTED", "TASK_ENDED", "EVENT_POSTED"}
def marker_counts(path: Path, command_id: str) -> dict[str, int]:
counts = {marker: 0 for marker in REQUIRED_MARKERS}
with path.open("r", encoding="utf-8") as handle:
for line in handle:
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
marker = record.get("marker")
if record.get("command_id") == command_id and marker in counts:
counts[marker] += 1
return counts
def watcher_status(database: Path, command_id: str) -> str | None:
with sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=2) as db:
row = db.execute("SELECT status FROM commands WHERE command_id=?", (command_id,)).fetchone()
return None if row is None else str(row[0])
def wait_for_cycle(config: dict[str, object], token: str, cycle: int) -> tuple[str, str]:
endpoint = str(config["gateway_event_url"])
project = str(config["project_key"])
event_id = f"a0-cycle-{cycle}-" + uuid.uuid4().hex
work_unit_id = f"A0-CODEX-CYCLE-{cycle}-" + uuid.uuid4().hex[:16]
event = {
"schema_version": 1,
"project_key": project,
"contract_commit": config["contract_commit"],
"event_id": event_id,
"source_agent": "system",
"event_type": "a0_unattended_acceptance_requested",
"work_unit_id": work_unit_id,
"status": "READY",
"state_version": state_version(endpoint, project, token),
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {
"acceptance_contract": {
"verdict": "DISPATCH",
"work_unit_id": work_unit_id,
"track_id": "A0_AUTOMATION",
"gate_id": "A0_AUTOMATION_CLOSED",
"primary_agent": "codex",
"fallback_agent": "openclaw",
"clean_base_commit": config["probe_clean_base_commit"],
"allowed_paths": [],
"required_checks": ["acceptance_probe"],
"forbidden_operations": ["production_mutation", "git_mutation"],
"instruction": "Run the fixed side-effect-free acceptance probe only.",
}
},
}
request_json(endpoint, token, method="POST", payload=event)
deadline = time.monotonic() + int(config["cycle_timeout_seconds"])
channel = None
while time.monotonic() < deadline:
_, status = request_json(endpoint + "/" + event_id, token)
if status.get("status") == "TRIGGER_FAILED":
raise ProbeError("central_trigger_failed")
if status.get("status") == "DECIDED" and status.get("decision_applied") is True:
channel = status.get("trigger_channel")
break
time.sleep(float(config["poll_interval_seconds"]))
if channel not in {"workspace_agent", "responses_api"}:
raise ProbeError("central_decision_timeout")
database = Path(str(config["codex_database_path"]))
audit = Path(str(config["codex_audit_log"]))
while time.monotonic() < deadline:
if database.exists() and audit.exists() and watcher_status(database, work_unit_id) == "COMPLETED":
counts = marker_counts(audit, work_unit_id)
if all(counts[marker] == 1 for marker in REQUIRED_MARKERS):
for suffix in ("started", "ended"):
_, lifecycle = request_json(endpoint + "/" + work_unit_id + ":" + suffix, token)
if lifecycle.get("event_id") != work_unit_id + ":" + suffix:
raise ProbeError("gateway_lifecycle_missing")
return event_id, str(channel)
time.sleep(float(config["poll_interval_seconds"]))
raise ProbeError("gateway_dispatch_or_codex_lifecycle_timeout")
def project_summaries_since(database: Path, project: str, since: int) -> int:
with sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=2) as db:
row = db.execute(
"SELECT COUNT(*) FROM lifecycle WHERE project_key=? AND kind='PROJECT_SUMMARY' AND created_at>=?",
(project, since),
).fetchone()
return 0 if row is None else int(row[0])
def run(config_path: Path) -> int:
config = load_config(config_path)
endpoint = str(config["gateway_event_url"])
gateway_base(endpoint)
token = read_token(Path(str(config["gateway_token_file"])))
started = int(time.time())
results = [wait_for_cycle(config, token, cycle) for cycle in (1, 2)]
if len({item[0] for item in results}) != 2:
raise ProbeError("duplicate_cycle_event")
if project_summaries_since(
Path(str(config["gateway_database_path"])), str(config["project_key"]), started
) < 2:
raise ProbeError("project_summary_not_persisted")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
args = parser.parse_args()
try:
return run(args.config)
except (KeyError, OSError, ProbeError, sqlite3.Error, ValueError):
return 1
if __name__ == "__main__":
raise SystemExit(main())
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Prove a real central-model decision through the authenticated Gateway."""
from __future__ import annotations
import argparse
import json
import os
import stat
import sys
import time
from pathlib import Path
import uuid
from health_probe_common import (
ProbeError, atomic_receipt, gateway_base, load_config, read_token,
request_json, state_version,
)
PENDING_PATH = Path("/run/aios-health/central-gpt.pending.json")
def _load_pending(
path: Path, contract: str, *, owners: set[int] | None = None
) -> tuple[str, int] | None:
allowed_owners = {0} if owners is None else owners
if not path.exists():
return None
info = path.lstat()
if (
not path.is_absolute()
or stat.S_ISLNK(info.st_mode)
or not stat.S_ISREG(info.st_mode)
or stat.S_IMODE(info.st_mode) & ~0o640
or (os.name == "posix" and info.st_uid not in allowed_owners)
or path.resolve(strict=True) != Path(os.path.abspath(path))
):
raise ProbeError("unsafe_pending_marker")
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or set(value) != {"contract_commit", "event_id", "state_version"}:
raise ProbeError("invalid_pending_marker")
event_id = value["event_id"]
version = value["state_version"]
if (
value["contract_commit"] != contract
or not isinstance(event_id, str)
or not event_id.startswith("health-central-")
or len(event_id) != 47
or not isinstance(version, int)
or version < 0
):
raise ProbeError("invalid_pending_marker")
return event_id, version
def _clear_pending(path: Path) -> None:
if not path.exists():
return
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise ProbeError("unsafe_pending_marker")
path.unlink()
def _safe_reason(exc: BaseException) -> str:
reason = str(exc)
if reason and len(reason) <= 64 and all(char.islower() or char.isdigit() or char == "_" for char in reason):
return reason
return "probe_error"
def run(config_path: Path, pending_path: Path = PENDING_PATH) -> int:
config = load_config(config_path)
endpoint = config["gateway_event_url"]
gateway_base(endpoint)
token = read_token(Path(config["gateway_token_file"]))
project = config["project_key"]
contract = config["contract_commit"]
pending = _load_pending(pending_path, contract)
is_new = pending is None
if is_new:
event_id = "health-central-" + uuid.uuid4().hex
version = state_version(endpoint, project, token)
atomic_receipt(
pending_path,
{"contract_commit": contract, "event_id": event_id, "state_version": version},
)
else:
event_id, version = pending
event = {
"schema_version": 1,
"project_key": project,
"contract_commit": contract,
"event_id": event_id,
"source_agent": "system",
"event_type": "central_gpt_health_probe",
"work_unit_id": "CENTRAL-GPT-HEALTH",
"status": "READY",
"state_version": version,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {"probe": "side_effect_free", "nonce": event_id},
}
should_post = is_new
if not is_new:
try:
_, observed = request_json(endpoint + "/" + event_id, token)
except ProbeError as exc:
if str(exc) != "gateway_http_404":
raise
should_post = True
else:
observed = None
if should_post:
try:
status, _ = request_json(endpoint, token, method="POST", payload=event)
except ProbeError as exc:
if str(exc) == "gateway_fail_closed_409":
_clear_pending(pending_path)
raise
if status not in {200, 202}:
raise ProbeError("event_not_accepted")
observed = None
deadline = time.monotonic() + int(config["central_timeout_seconds"])
while time.monotonic() < deadline:
_, observed = request_json(endpoint + "/" + event_id, token)
if observed.get("status") in {"DECIDED", "TRIGGER_FAILED", "DEAD_LETTER"}:
break
time.sleep(float(config["poll_interval_seconds"]))
if isinstance(observed, dict) and observed.get("status") == "DEAD_LETTER":
_clear_pending(pending_path)
raise ProbeError("central_event_dead_letter")
if not isinstance(observed, dict) or observed.get("status") != "DECIDED" or observed.get("decision_applied") is not True:
raise ProbeError("central_decision_not_proven")
channel = observed.get("trigger_channel")
if channel not in {"workspace_agent", "responses_api"}:
raise ProbeError("central_channel_not_proven")
_clear_pending(pending_path)
now = int(time.time())
atomic_receipt(
Path(config["central_receipt_path"]),
{
"subject": "central_gpt",
"health": "HEALTHY",
"live_probe_succeeded": True,
"observed_at_epoch": now,
"event_id": event_id,
"decision_applied": True,
"state_version_after": observed["state_version_after"],
"workspace_agent": {"validated_live": channel == "workspace_agent"},
"responses_api": {"validated_live": channel == "responses_api"},
},
)
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
args = parser.parse_args()
try:
return run(args.config)
except (KeyError, OSError, ProbeError, ValueError) as exc:
print(f"CENTRAL_GPT_PROBE=BLOCKED reason={_safe_reason(exc)}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Queue one fixed no-side-effect command and prove the watcher lifecycle."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sqlite3
import stat
import time
import uuid
from health_probe_common import (
ProbeError, atomic_receipt, gateway_base, load_config, read_token,
request_json, state_version,
)
MARKERS = {"CODEX_AUTO_CLAIM", "TASK_STARTED", "TASK_EXECUTED", "TASK_ENDED", "EVENT_POSTED"}
def _write_command(path: Path, value: dict[str, object]) -> None:
directory = path.parent
info = directory.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o002:
raise ProbeError("unsafe_command_queue")
data = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o640)
try:
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
def _status(database: Path, command_id: str) -> str | None:
with sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=2) as db:
row = db.execute("SELECT status FROM commands WHERE command_id=?", (command_id,)).fetchone()
return None if row is None else str(row[0])
def _markers(path: Path, command_id: str) -> dict[str, int]:
result = {name: 0 for name in MARKERS}
with path.open("r", encoding="utf-8") as handle:
for line in handle:
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if item.get("command_id") == command_id and item.get("marker") in result:
result[item["marker"]] += 1
return result
def run(config_path: Path) -> int:
config = load_config(config_path)
endpoint = config["gateway_event_url"]
gateway_base(endpoint)
token = read_token(Path(config["gateway_token_file"]))
command_id = "health-codex-" + uuid.uuid4().hex
version = state_version(endpoint, config["project_key"], token)
command = {
"schema_version": 1,
"command_id": command_id,
"project_key": config["project_key"],
"work_unit_id": command_id,
"contract_commit": config["contract_commit"],
"state_version": version,
"execution": {"kind": "acceptance_probe", "input": "fixed-side-effect-free-health-probe-v1"},
}
_write_command(Path(config["codex_command_queue"]) / f"{command_id}.json", command)
deadline = time.monotonic() + int(config["codex_timeout_seconds"])
database = Path(config["codex_database_path"])
audit = Path(config["codex_audit_log"])
while time.monotonic() < deadline:
if database.exists() and audit.exists() and _status(database, command_id) == "COMPLETED":
counts = _markers(audit, command_id)
if all(counts[name] == 1 for name in MARKERS):
for suffix in ("started", "ended"):
_, event = request_json(endpoint + "/" + command_id + ":" + suffix, token)
if event.get("event_id") != command_id + ":" + suffix:
raise ProbeError("gateway_lifecycle_missing")
atomic_receipt(
Path(config["codex_receipt_path"]),
{
"subject": "codex",
"health": "HEALTHY",
"live_probe_succeeded": True,
"observed_at_epoch": int(time.time()),
"command_id": command_id,
"lifecycle_complete": True,
"duplicate_side_effects": False,
},
)
return 0
time.sleep(float(config["poll_interval_seconds"]))
raise ProbeError("codex_lifecycle_not_proven")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
args = parser.parse_args()
try:
return run(args.config)
except (KeyError, OSError, ProbeError, sqlite3.Error, ValueError):
return 1
if __name__ == "__main__":
raise SystemExit(main())
+940
View File
@@ -0,0 +1,940 @@
#!/usr/bin/env python3
"""Durable GPT + one-executor quorum and failover controller.
The controller persists decisions before dispatch and never stores credentials.
Health is accepted only from metadata-only, live-probe receipts.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import stat
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import urllib.parse
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping
ALLOWED_LIFECYCLE_EVENTS = frozenset(
{
"TASK_DISPATCHED",
"TASK_STARTED",
"TASK_ENDED",
"DECISION_APPLIED",
"PROJECT_SUMMARY",
"PROJECT_COMPLETED",
}
)
EXECUTORS = ("openclaw", "codex")
SECRET_KEYS = frozenset(
{
"secret",
"token",
"password",
"api_key",
"authorization",
"credential",
"private_key",
"environment",
"env",
"argv",
"stdout",
"stderr",
}
)
class Health(str, Enum):
HEALTHY = "HEALTHY"
UNHEALTHY = "UNHEALTHY"
UNKNOWN = "UNKNOWN"
class ControllerError(RuntimeError):
"""Fail-closed controller error."""
class EventConflict(ControllerError):
"""An event id was reused with a different payload."""
class StaleStateVersion(ControllerError):
"""A dispatch attempted to overwrite a newer task state."""
@dataclass(frozen=True)
class QuorumDecision:
allowed: bool
priority: str
status: str
active_executor: str | None
standby_executor: str | None
repair_required: bool
def canonical_json(value: Any) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def digest_json(value: Any) -> str:
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
def _contains_forbidden_key(value: Any) -> bool:
if isinstance(value, Mapping):
for key, child in value.items():
normalized = str(key).strip().lower().replace("-", "_")
if normalized in SECRET_KEYS or _contains_forbidden_key(child):
return True
elif isinstance(value, list):
return any(_contains_forbidden_key(item) for item in value)
return False
def validate_metadata_only(value: Mapping[str, Any]) -> None:
if _contains_forbidden_key(value):
raise ControllerError("secret-bearing or output-bearing fields are forbidden")
def atomic_json_write(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
validate_secure_directory(path.parent)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(canonical_json(value))
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def validate_secure_directory(path: Path, *, expected_uid: int | None = None) -> None:
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ControllerError(f"unsafe directory type rejected: {path}")
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
raise ControllerError(f"symlinked directory component rejected: {path}")
if os.name == "posix" and info.st_mode & 0o022:
raise ControllerError(f"group/world-writable directory rejected: {path}")
if expected_uid is not None and info.st_uid != expected_uid:
raise ControllerError(f"unexpected owner for directory {path}")
def read_secure_json(path: Path, *, expected_uid: int | None = None) -> dict[str, Any]:
info = path.lstat()
if stat.S_ISLNK(info.st_mode):
raise ControllerError(f"symlink rejected: {path}")
if not stat.S_ISREG(info.st_mode):
raise ControllerError(f"not a regular file: {path}")
if os.name == "posix" and info.st_mode & 0o022:
raise ControllerError(f"group/world-writable file rejected: {path}")
validate_secure_directory(path.parent, expected_uid=expected_uid)
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
raise ControllerError(f"symlinked file component rejected: {path}")
if expected_uid is not None and info.st_uid != expected_uid:
raise ControllerError(f"unexpected owner for {path}")
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ControllerError(f"JSON object required: {path}")
validate_metadata_only(value)
return value
def read_gateway_token(path: Path) -> str:
if not path.is_absolute():
raise ControllerError("gateway token path must be absolute")
try:
info = path.lstat()
except FileNotFoundError as exc:
raise ControllerError("gateway token unavailable") from exc
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise ControllerError("gateway token must be a regular non-symlink file")
if stat.S_IMODE(info.st_mode) & ~0o640:
raise ControllerError("gateway token mode exceeds 0640")
if os.name == "posix" and info.st_uid not in {0, os.geteuid()}:
raise ControllerError("gateway token owner is not approved")
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
raise ControllerError("gateway token has a symlinked path component")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags)
try:
opened = os.fstat(fd)
if not stat.S_ISREG(opened.st_mode) or opened.st_ino != info.st_ino or opened.st_dev != info.st_dev:
raise ControllerError("gateway token changed during validation")
data = os.read(fd, 16_385)
if os.read(fd, 1):
raise ControllerError("gateway token too large")
finally:
os.close(fd)
if not data or len(data) > 16_384 or b"\n" in data or b"\r" in data:
raise ControllerError("gateway token format rejected")
try:
token = data.decode("utf-8")
except UnicodeDecodeError as exc:
raise ControllerError("gateway token encoding rejected") from exc
if not token:
raise ControllerError("gateway token unavailable")
return token
def validate_health_receipt(
receipt: Mapping[str, Any],
subject: str,
*,
now: float | None = None,
max_age_seconds: int = 60,
) -> Health:
validate_metadata_only(receipt)
if receipt.get("subject") != subject:
return Health.UNKNOWN
try:
health = Health(str(receipt.get("health", "UNKNOWN")))
except ValueError:
return Health.UNKNOWN
if health is not Health.HEALTHY:
return health
if receipt.get("live_probe_succeeded") is not True:
return Health.UNKNOWN
observed_at = receipt.get("observed_at_epoch")
if not isinstance(observed_at, (int, float)):
return Health.UNKNOWN
now = time.time() if now is None else now
if observed_at > now + 5 or now - observed_at > max_age_seconds:
return Health.UNKNOWN
if subject == "central_gpt":
workspace = receipt.get("workspace_agent")
responses = receipt.get("responses_api")
workspace_ok = isinstance(workspace, Mapping) and workspace.get("validated_live") is True
responses_ok = isinstance(responses, Mapping) and responses.get("validated_live") is True
if not (workspace_ok or responses_ok):
return Health.UNKNOWN
return Health.HEALTHY
def choose_executor(workload: str) -> str:
if workload in {"complex_code", "security_boundary", "architecture", "complex_code_security_architecture"}:
return "codex"
return "openclaw"
def evaluate_quorum(
central_gpt: Health,
openclaw: Health,
codex: Health,
workload: str,
) -> QuorumDecision:
if central_gpt is not Health.HEALTHY:
status = "BLOCKED_CENTRAL_GPT_UNKNOWN" if central_gpt is Health.UNKNOWN else "BLOCKED_CENTRAL_GPT_UNHEALTHY"
return QuorumDecision(False, "P0", status, None, None, True)
healthy = [name for name, health in (("openclaw", openclaw), ("codex", codex)) if health is Health.HEALTHY]
if not healthy:
has_unknown = Health.UNKNOWN in {openclaw, codex}
status = "BLOCKED_EXECUTOR_HEALTH_UNKNOWN" if has_unknown else "BLOCKED_NO_HEALTHY_EXECUTOR"
return QuorumDecision(False, "P1", status, None, None, True)
if len(healthy) == 1:
active = healthy[0]
standby = "codex" if active == "openclaw" else "openclaw"
return QuorumDecision(True, "P3", "EXECUTOR_QUORUM_PASS", active, standby, False)
active = choose_executor(workload)
standby = "codex" if active == "openclaw" else "openclaw"
return QuorumDecision(True, "NONE", "EXECUTOR_QUORUM_PASS", active, standby, False)
class QuorumStore:
def __init__(self, path: Path):
self.path = path
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
expected_uid = os.geteuid() if os.name == "posix" else None
validate_secure_directory(path.parent, expected_uid=expected_uid)
if path.exists() or path.is_symlink():
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
raise ControllerError(f"unsafe database path rejected: {path}")
if expected_uid is not None and info.st_uid != expected_uid:
raise ControllerError(f"unexpected database owner: {path}")
self.db = sqlite3.connect(str(path), isolation_level=None, timeout=30)
if os.name == "posix":
path.chmod(0o600)
self.db.row_factory = sqlite3.Row
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA synchronous=FULL")
self._initialize()
def close(self) -> None:
self.db.close()
def _initialize(self) -> None:
self.db.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
payload_digest TEXT NOT NULL,
payload_json TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
work_unit TEXT PRIMARY KEY,
contract_commit TEXT NOT NULL,
event_id TEXT NOT NULL,
state_version INTEGER NOT NULL,
workload TEXT NOT NULL,
status TEXT NOT NULL,
active_executor TEXT,
standby_executor TEXT,
checkpoint TEXT,
lease_owner TEXT,
lease_until REAL,
interrupted_executor TEXT,
recovery_priority TEXT NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS lifecycle (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
work_unit TEXT NOT NULL,
event_type TEXT NOT NULL,
executor TEXT,
state_version INTEGER NOT NULL,
metadata_json TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS effects (
effect_id TEXT PRIMARY KEY,
work_unit TEXT NOT NULL,
payload_digest TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
outbox_id TEXT PRIMARY KEY,
destination TEXT NOT NULL,
payload_digest TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt REAL NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS blockers (
blocker_key TEXT PRIMARY KEY,
occurrences INTEGER NOT NULL,
status TEXT NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS observed_refs (
ref_name TEXT PRIMARY KEY,
oid TEXT NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS summaries (
work_unit TEXT PRIMARY KEY,
summary_digest TEXT NOT NULL,
summary_json TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS lifecycle_once
ON lifecycle(work_unit, event_type, COALESCE(executor, ''), state_version);
"""
)
def ingest_event(self, event_id: str, payload: Mapping[str, Any], source: str) -> bool:
validate_metadata_only(payload)
encoded = canonical_json(payload)
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
existing = self.db.execute("SELECT payload_digest FROM events WHERE event_id=?", (event_id,)).fetchone()
if existing:
if existing["payload_digest"] != digest:
self.db.execute("UPDATE events SET status='DEAD_LETTER_CONFLICT' WHERE event_id=?", (event_id,))
raise EventConflict(f"event id payload conflict: {event_id}")
return False
self.db.execute(
"INSERT INTO events VALUES(?,?,?,?,?,?)",
(event_id, digest, encoded, source, "ACCEPTED", time.time()),
)
return True
def upsert_task(
self,
*,
work_unit: str,
contract_commit: str,
event_id: str,
state_version: int,
workload: str,
decision: QuorumDecision,
checkpoint: str | None = None,
) -> str:
now = time.time()
self.db.execute("BEGIN IMMEDIATE")
try:
current = self.db.execute("SELECT * FROM tasks WHERE work_unit=?", (work_unit,)).fetchone()
if current and state_version < current["state_version"]:
raise StaleStateVersion(f"stale state version for {work_unit}")
interrupted: str | None = None
recovery_priority = decision.priority
status = "BLOCKED"
if decision.allowed:
status = "READY"
if current and current["active_executor"] and current["active_executor"] != decision.active_executor:
interrupted = current["active_executor"]
recovery_priority = "P2"
status = "INTERRUPTED_AGENT_FAILOVER"
if checkpoint is None:
checkpoint = current["checkpoint"]
if current:
if contract_commit != current["contract_commit"] or event_id != current["event_id"]:
raise ControllerError("failover must preserve contract_commit and event_id")
if (
current["state_version"] == state_version
and current["active_executor"] == decision.active_executor
and current["status"] in {"RUNNING", "COMPLETED"}
):
self.db.execute("COMMIT")
return current["status"]
self.db.execute(
"""UPDATE tasks SET state_version=?, workload=?, status=?, active_executor=?,
standby_executor=?, checkpoint=?, interrupted_executor=?, recovery_priority=?, updated_at=? WHERE work_unit=?""",
(
state_version,
workload,
status,
decision.active_executor,
decision.standby_executor,
checkpoint,
interrupted,
recovery_priority,
now,
work_unit,
),
)
else:
self.db.execute(
"INSERT INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
work_unit,
contract_commit,
event_id,
state_version,
workload,
status,
decision.active_executor,
decision.standby_executor,
checkpoint,
None,
None,
interrupted,
recovery_priority,
now,
),
)
self.db.execute("COMMIT")
return status
except Exception:
self.db.execute("ROLLBACK")
raise
def claim_lease(self, work_unit: str, worker: str, ttl_seconds: int, now: float | None = None) -> bool:
now = time.time() if now is None else now
self.db.execute("BEGIN IMMEDIATE")
try:
task = self.db.execute(
"SELECT active_executor, status, lease_owner, lease_until FROM tasks WHERE work_unit=?",
(work_unit,),
).fetchone()
if not task:
raise ControllerError(f"unknown work unit: {work_unit}")
if worker != task["active_executor"]:
self.db.execute("ROLLBACK")
return False
if task["status"] in {"BLOCKED", "COMPLETED"}:
self.db.execute("ROLLBACK")
return False
lease_live = task["lease_owner"] and task["lease_until"] and task["lease_until"] > now
if lease_live and task["lease_owner"] != worker:
self.db.execute("ROLLBACK")
return False
self.db.execute(
"UPDATE tasks SET lease_owner=?, lease_until=?, status='RUNNING', updated_at=? WHERE work_unit=?",
(worker, now + ttl_seconds, now, work_unit),
)
self.db.execute("COMMIT")
return True
except Exception:
if self.db.in_transaction:
self.db.execute("ROLLBACK")
raise
def dispatch_task(
self,
*,
work_unit: str,
contract_commit: str,
event_id: str,
state_version: int,
workload: str,
decision: QuorumDecision,
checkpoint: str | None = None,
) -> str:
status = self.upsert_task(
work_unit=work_unit,
contract_commit=contract_commit,
event_id=event_id,
state_version=state_version,
workload=workload,
decision=decision,
checkpoint=checkpoint,
)
if decision.allowed and status != "COMPLETED":
metadata = {
"contract_commit": contract_commit,
"event_id": event_id,
"status": status,
}
self.append_lifecycle(work_unit, "TASK_DISPATCHED", decision.active_executor, state_version, metadata)
self.enqueue(
f"dispatch:{work_unit}:{state_version}:{decision.active_executor}",
"gateway",
{"event_type": "TASK_DISPATCHED", "work_unit": work_unit, **metadata},
)
return status
def start_task(self, work_unit: str, executor: str, state_version: int, lease_ttl_seconds: int) -> bool:
if not self.claim_lease(work_unit, executor, lease_ttl_seconds):
return False
self.append_lifecycle(work_unit, "TASK_STARTED", executor, state_version, {"status": "RUNNING"})
self.enqueue(
f"started:{work_unit}:{state_version}:{executor}",
"gateway",
{"event_type": "TASK_STARTED", "work_unit": work_unit, "executor": executor, "state_version": state_version},
)
return True
def end_task(
self,
work_unit: str,
executor: str,
state_version: int,
result: str,
summary: Mapping[str, Any],
*,
project_completed: bool = False,
) -> None:
validate_metadata_only(summary)
self.db.execute("BEGIN IMMEDIATE")
try:
task = self.db.execute("SELECT active_executor,state_version FROM tasks WHERE work_unit=?", (work_unit,)).fetchone()
if not task or task["active_executor"] != executor:
raise ControllerError("only the active executor may end a task")
if task["state_version"] != state_version:
raise StaleStateVersion(f"stale state version for {work_unit}")
self.db.execute(
"UPDATE tasks SET status='COMPLETED', lease_owner=NULL, lease_until=NULL, updated_at=? WHERE work_unit=?",
(time.time(), work_unit),
)
self.append_lifecycle(work_unit, "TASK_ENDED", executor, state_version, {"result": result})
self.append_lifecycle(work_unit, "DECISION_APPLIED", executor, state_version, {"result": result})
self.append_lifecycle(work_unit, "PROJECT_SUMMARY", executor, state_version, summary)
if project_completed:
self.append_lifecycle(work_unit, "PROJECT_COMPLETED", executor, state_version, {"result": result})
self.persist_summary(work_unit, summary)
outbound = (
("TASK_ENDED", {"result": result}),
("DECISION_APPLIED", {"result": result}),
("PROJECT_SUMMARY", dict(summary)),
)
for event_type, metadata in outbound:
self.enqueue(
f"{event_type.lower()}:{work_unit}:{state_version}:{executor}",
"gateway",
{
"event_type": event_type,
"work_unit": work_unit,
"executor": executor,
"state_version": state_version,
**metadata,
},
)
if project_completed:
self.enqueue(
f"project_completed:{work_unit}:{state_version}:{executor}",
"gateway",
{
"event_type": "PROJECT_COMPLETED",
"work_unit": work_unit,
"executor": executor,
"state_version": state_version,
"result": result,
},
)
self.db.execute("COMMIT")
except Exception:
if self.db.in_transaction:
self.db.execute("ROLLBACK")
raise
def record_effect(self, effect_id: str, work_unit: str, payload: Mapping[str, Any]) -> bool:
validate_metadata_only(payload)
digest = digest_json(payload)
existing = self.db.execute("SELECT payload_digest FROM effects WHERE effect_id=?", (effect_id,)).fetchone()
if existing:
if existing["payload_digest"] != digest:
raise EventConflict(f"effect id payload conflict: {effect_id}")
return False
self.db.execute(
"INSERT INTO effects VALUES(?,?,?,?,?)",
(effect_id, work_unit, digest, "COMPLETED", time.time()),
)
return True
def append_lifecycle(
self,
work_unit: str,
event_type: str,
executor: str | None,
state_version: int,
metadata: Mapping[str, Any],
) -> None:
if event_type not in ALLOWED_LIFECYCLE_EVENTS:
raise ControllerError(f"unsupported lifecycle event: {event_type}")
validate_metadata_only(metadata)
self.db.execute(
"INSERT OR IGNORE INTO lifecycle(work_unit,event_type,executor,state_version,metadata_json,created_at) VALUES(?,?,?,?,?,?)",
(work_unit, event_type, executor, state_version, canonical_json(metadata), time.time()),
)
def persist_summary(self, work_unit: str, summary: Mapping[str, Any]) -> None:
validate_metadata_only(summary)
encoded = canonical_json(summary)
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
self.db.execute(
"INSERT OR REPLACE INTO summaries VALUES(?,?,?,?)",
(work_unit, digest, encoded, time.time()),
)
def enqueue(self, outbox_id: str, destination: str, payload: Mapping[str, Any]) -> bool:
validate_metadata_only(payload)
encoded = canonical_json(payload)
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
existing = self.db.execute("SELECT payload_digest FROM outbox WHERE outbox_id=?", (outbox_id,)).fetchone()
if existing:
if existing["payload_digest"] != digest:
raise EventConflict(f"outbox id payload conflict: {outbox_id}")
return False
self.db.execute(
"INSERT INTO outbox VALUES(?,?,?,?,?,?,?,?)",
(outbox_id, destination, digest, encoded, "PENDING", 0, time.time(), time.time()),
)
return True
def record_blocker(self, blocker_key: str, threshold: int) -> str:
now = time.time()
row = self.db.execute("SELECT occurrences FROM blockers WHERE blocker_key=?", (blocker_key,)).fetchone()
occurrences = (row["occurrences"] if row else 0) + 1
status = "DEAD_LETTER" if occurrences > threshold else "RETRY"
self.db.execute(
"INSERT INTO blockers VALUES(?,?,?,?) ON CONFLICT(blocker_key) DO UPDATE SET occurrences=excluded.occurrences,status=excluded.status,updated_at=excluded.updated_at",
(blocker_key, occurrences, status, now),
)
return status
def deliver_outbox(self, sender: Callable[[Mapping[str, Any]], None], max_attempts: int, now: float | None = None) -> tuple[int, int]:
now = time.time() if now is None else now
sent = 0
failed = 0
rows = self.db.execute(
"SELECT * FROM outbox WHERE status='PENDING' AND next_attempt<=? ORDER BY created_at,outbox_id LIMIT 100",
(now,),
).fetchall()
for row in rows:
payload = json.loads(row["payload_json"])
validate_metadata_only(payload)
try:
sender(payload)
except Exception:
attempts = row["attempts"] + 1
status = "DEAD_LETTER" if attempts >= max_attempts else "PENDING"
delay = min(300, 2 ** min(attempts, 8))
self.db.execute(
"UPDATE outbox SET attempts=?,status=?,next_attempt=? WHERE outbox_id=?",
(attempts, status, now + delay, row["outbox_id"]),
)
failed += 1
else:
self.db.execute(
"UPDATE outbox SET attempts=attempts+1,status='SENT',next_attempt=? WHERE outbox_id=?",
(now, row["outbox_id"]),
)
sent += 1
return sent, failed
def observe_ref(self, ref_name: str, oid: str) -> bool:
row = self.db.execute("SELECT oid FROM observed_refs WHERE ref_name=?", (ref_name,)).fetchone()
changed = row is not None and row["oid"] != oid
self.db.execute(
"INSERT INTO observed_refs VALUES(?,?,?) ON CONFLICT(ref_name) DO UPDATE SET oid=excluded.oid,updated_at=excluded.updated_at",
(ref_name, oid, time.time()),
)
return changed
def spool_after_http_failure(
sender: Callable[[Mapping[str, Any]], None],
payload: Mapping[str, Any],
spool_dir: Path,
event_id: str,
) -> bool:
validate_metadata_only(payload)
try:
sender(payload)
return True
except Exception:
atomic_json_write(spool_dir / f"{event_id}.json", {"event_id": event_id, "payload": dict(payload)})
return False
def poll_file_inbox(store: QuorumStore, inbox_dir: Path) -> int:
if not inbox_dir.exists():
return 0
validate_secure_directory(inbox_dir, expected_uid=os.geteuid() if os.name == "posix" else None)
count = 0
for path in sorted(inbox_dir.glob("*.json")):
if path.is_symlink():
raise ControllerError(f"symlink inbox entry rejected: {path}")
item = read_secure_json(path, expected_uid=os.geteuid() if os.name == "posix" else None)
event_id = item.get("event_id")
payload = item.get("payload")
if not isinstance(event_id, str) or not isinstance(payload, dict):
raise ControllerError(f"invalid inbox envelope: {path}")
if store.ingest_event(event_id, payload, "file-inbox"):
count += 1
path.unlink()
return count
def resolve_git_ref(repo: Path, ref_name: str) -> str:
if not repo.is_absolute():
raise ControllerError("Git repository path must be absolute")
info = repo.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ControllerError("Git repository must be a non-symlink directory")
if os.name == "posix" and repo.resolve(strict=True) != Path(os.path.abspath(repo)):
raise ControllerError("Git repository has a symlinked path component")
if os.name == "posix" and info.st_mode & 0o002:
raise ControllerError("world-writable Git repository rejected")
try:
completed = subprocess.run(
[
"git",
"-c",
f"safe.directory={repo}",
"-C",
str(repo),
"rev-parse",
"--verify",
ref_name,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
raise ControllerError("Git ref unavailable") from exc
oid = completed.stdout.strip()
if len(oid) != 40 or any(character not in "0123456789abcdef" for character in oid.lower()):
raise ControllerError("invalid Git object id")
return oid
def poll_git_refs(
store: QuorumStore,
repo: Path,
refs: Iterable[str],
resolver: Callable[[Path, str], str] = resolve_git_ref,
) -> int:
changed = 0
for ref_name in refs:
if not ref_name.startswith("refs/") or ".." in ref_name or ref_name.endswith("/"):
raise ControllerError(f"invalid Git ref: {ref_name}")
oid = resolver(repo, ref_name)
if store.observe_ref(ref_name, oid):
event_id = f"gitref:{ref_name}:{oid}"
if store.ingest_event(event_id, {"ref": ref_name, "oid": oid}, "git-ref-poll"):
changed += 1
return changed
def responses_fallback_allowed(
workspace_agent_ok: bool,
receipt: Mapping[str, Any],
*,
now: float | None = None,
) -> bool:
validate_metadata_only(receipt)
if workspace_agent_ok:
return False
responses = receipt.get("responses_api")
return (
validate_health_receipt(receipt, "central_gpt", now=now) is Health.HEALTHY
and isinstance(responses, Mapping)
and responses.get("validated_live") is True
)
def _validate_gateway_url(url: str) -> None:
parsed = urllib.parse.urlparse(url)
if (
parsed.scheme != "http"
or parsed.hostname != "127.0.0.1"
or parsed.port != 18920
or parsed.path != "/v1/orchestrator/events"
or parsed.params
or parsed.query
or parsed.fragment
):
raise ControllerError("gateway endpoint must be the fixed loopback endpoint")
def get_gateway_state(url: str, project_key: str, token: str, timeout: float = 5.0) -> int:
_validate_gateway_url(url)
state_url = url.removesuffix("/events") + "/state/" + urllib.parse.quote(project_key, safe="")
request = urllib.request.Request(
state_url,
headers={"Authorization": f"Bearer {token}"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read(262_145))
except urllib.error.HTTPError as exc:
if exc.code == 404:
return 0
raise ControllerError(f"gateway state rejected HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise ControllerError("gateway state unavailable") from exc
version = payload.get("state_version") if isinstance(payload, Mapping) else None
if not isinstance(version, int) or version < 0:
raise ControllerError("gateway state version invalid")
return version
def post_json(url: str, payload: Mapping[str, Any], token: str, timeout: float = 5.0) -> None:
validate_metadata_only(payload)
_validate_gateway_url(url)
request = urllib.request.Request(
url,
data=canonical_json(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
if response.status < 200 or response.status >= 300:
raise ControllerError(f"gateway rejected HTTP {response.status}")
except urllib.error.HTTPError as exc:
raise ControllerError(f"gateway rejected HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise ControllerError("gateway unavailable") from exc
def run_once(config_path: Path) -> int:
config = read_secure_json(config_path, expected_uid=0 if os.name == "posix" else None)
gateway_url = config["gateway_event_url"]
token = read_gateway_token(Path(config["gateway_token_file"]))
state_version = get_gateway_state(gateway_url, config["project_key"], token)
store = QuorumStore(Path(config["database_path"]))
try:
receipts: dict[str, Health] = {}
for subject in ("central_gpt", "openclaw", "codex"):
receipt_path = Path(config["health_receipts"][subject])
try:
receipt = read_secure_json(receipt_path, expected_uid=0 if os.name == "posix" else None)
receipts[subject] = validate_health_receipt(
receipt,
subject,
max_age_seconds=int(config.get("max_health_age_seconds", 60)),
)
except (FileNotFoundError, ControllerError, json.JSONDecodeError):
receipts[subject] = Health.UNKNOWN
decision = evaluate_quorum(
receipts["central_gpt"], receipts["openclaw"], receipts["codex"], config.get("default_workload", "large_repetitive_infrastructure_monitoring")
)
quorum_event_id = f"quorum-{time.time_ns()}"
store.enqueue(
quorum_event_id,
"gateway",
{
"schema_version": 1,
"project_key": config["project_key"],
"contract_commit": config["contract_commit"],
"event_id": quorum_event_id,
"source_agent": "system",
"event_type": "executor_quorum_evaluated",
"work_unit_id": "A0-AUTOMATION-QUORUM",
"status": "READY" if decision.allowed else "BLOCKED",
"state_version": state_version,
"implementation_commit": None,
"result_commit": None,
"blocker": None if decision.allowed else decision.status,
"evidence_refs": [],
"payload": {
"allowed": decision.allowed,
"priority": decision.priority,
"quorum_status": decision.status,
"active_executor": decision.active_executor,
"standby_executor": decision.standby_executor,
},
},
)
poll_file_inbox(store, Path(config["file_inbox_path"]))
git_polling = config.get("git_ref_polling")
if isinstance(git_polling, Mapping):
poll_git_refs(store, Path(git_polling["repository"]), git_polling["refs"])
store.deliver_outbox(
lambda payload: post_json(gateway_url, payload, token),
max_attempts=int(config["blocker_retry_threshold"]),
)
return 0 if decision.allowed else 2
finally:
store.close()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--once", action="store_true", required=True)
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return run_once(args.config)
except (ControllerError, KeyError, OSError, sqlite3.Error, json.JSONDecodeError) as error:
print(f"automation-quorum-controller: fail-closed: {type(error).__name__}", file=os.sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Strict metadata-only helpers shared by root health probes."""
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
import tempfile
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
class ProbeError(RuntimeError):
pass
def secure_regular(path: Path, *, max_mode: int, owners: set[int]) -> os.stat_result:
if not path.is_absolute():
raise ProbeError("path_not_absolute")
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise ProbeError("unsafe_file_type")
if stat.S_IMODE(info.st_mode) & ~max_mode:
raise ProbeError("unsafe_file_mode")
if os.name == "posix" and info.st_uid not in owners:
raise ProbeError("unsafe_file_owner")
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
raise ProbeError("symlinked_path_component")
return info
def load_config(path: Path) -> dict[str, Any]:
secure_regular(path, max_mode=0o600, owners={0})
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ProbeError("config_not_object")
return value
def read_token(path: Path) -> str:
info = secure_regular(path, max_mode=0o640, owners={0, os.geteuid()})
if info.st_size <= 0 or info.st_size > 16_384:
raise ProbeError("invalid_token_size")
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
try:
opened = os.fstat(fd)
if not stat.S_ISREG(opened.st_mode) or opened.st_ino != info.st_ino or opened.st_dev != info.st_dev:
raise ProbeError("token_changed_during_validation")
data = os.read(fd, 16_385)
if os.read(fd, 1):
raise ProbeError("invalid_token_size")
finally:
os.close(fd)
if b"\n" in data or b"\r" in data:
raise ProbeError("invalid_token_format")
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
raise ProbeError("invalid_token_encoding") from exc
def gateway_base(endpoint: str) -> str:
parsed = urllib.parse.urlparse(endpoint)
if (
parsed.scheme != "http"
or parsed.hostname != "127.0.0.1"
or parsed.port != 18920
or parsed.path != "/v1/orchestrator/events"
or parsed.query
or parsed.fragment
):
raise ProbeError("invalid_gateway_endpoint")
return endpoint.removesuffix("/events")
def request_json(
url: str,
token: str,
*,
method: str = "GET",
payload: dict[str, Any] | None = None,
timeout: float = 10.0,
) -> tuple[int, dict[str, Any]]:
data = None if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
headers = {"Authorization": f"Bearer {token}"}
if data is not None:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = json.loads(response.read(262_145))
if not isinstance(body, dict):
raise ProbeError("gateway_response_not_object")
return response.status, body
except urllib.error.HTTPError as exc:
if exc.code in {401, 403, 409, 422}:
raise ProbeError(f"gateway_fail_closed_{exc.code}") from exc
raise ProbeError(f"gateway_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise ProbeError("gateway_unavailable") from exc
def state_version(endpoint: str, project_key: str, token: str) -> int:
url = gateway_base(endpoint) + "/state/" + urllib.parse.quote(project_key, safe="")
try:
_, value = request_json(url, token)
except ProbeError as exc:
if str(exc) == "gateway_http_404":
return 0
raise
version = value.get("state_version")
if not isinstance(version, int) or version < 0:
raise ProbeError("invalid_state_version")
return version
def atomic_receipt(path: Path, value: dict[str, Any]) -> None:
if not path.is_absolute() or path.parent != Path("/run/aios-health"):
raise ProbeError("invalid_receipt_path")
parent = path.parent
info = parent.lstat()
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ProbeError("unsafe_receipt_directory")
if os.name == "posix" and (info.st_uid != 0 or stat.S_IMODE(info.st_mode) != 0o750):
raise ProbeError("unsafe_receipt_directory_metadata")
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=parent)
try:
# Receipts contain availability metadata only. Give the dedicated
# aios-quorum group read access so its unprivileged controller can
# consume them, while retaining root ownership and no public access.
if os.name == "posix":
os.fchown(fd, 0, info.st_gid)
os.fchmod(fd, 0o640)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(value, handle, sort_keys=True, separators=(",", ":"))
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
+12 -24
View File
@@ -1,25 +1,13 @@
{
"current_baseline": "V2R1C2",
"previous_baseline": "V2R1C1",
"promoted_at": "2026-07-22T01:40:51Z",
"git": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"promoted_by": "codex/owner-approved-gitops",
"promotion_reason": "Owner-authorized V2R1C2 Unified Portal production release after exact tag binding, complete evidence verification, and production acceptance PASS.",
"version": "V2R1C2",
"release_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
"status": "PRODUCTION_DEPLOYED",
"production_service": "aios-v2r1c2-production.service",
"production_port": 18920,
"rollback_version": "V2R1C1",
"constraints": {
"product_code_changed": true,
"production_redeployed": true,
"production_traffic_changed": true,
"tag_rewritten": false,
"force_push_allowed": false,
"rollback_assets_preserved": true
}
}
"current_baseline": "V1R4C6",
"previous_baseline": "V1R4C5",
"promoted_at": "2026-07-11T21:00:00+08:00",
"git": "e204a90398bf646d46245b3088226749fa056edd",
"promoted_by": "openclaw/admin",
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
"constraints": {
"memory_search_enabled": false,
"force_push_allowed": false,
"gitea_db_mutation_requires_owner_approval": true
}
}
@@ -0,0 +1,24 @@
{
"$schema": "../schemas/automation-quorum-controller.schema.json",
"project_key": "v2r1-personal-weixin-production",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"database_path": "/var/lib/aios-automation-quorum/controller.sqlite3",
"file_inbox_path": "/var/lib/aios-automation-quorum/inbox",
"default_workload": "large_repetitive_infrastructure_monitoring",
"health_receipts": {
"central_gpt": "/run/aios-health/central-gpt.json",
"openclaw": "/run/aios-health/openclaw.json",
"codex": "/run/aios-health/codex.json"
},
"git_ref_polling": {
"repository": "/data/aios-gitops/repo.git",
"refs": [
"refs/heads/task/governance/v2r1-personal-weixin-production-contract-20260717"
]
},
"blocker_retry_threshold": 3,
"max_health_age_seconds": 60,
"lease_ttl_seconds": 120,
"gateway_event_url": "http://127.0.0.1:18920/v1/orchestrator/events",
"gateway_token_file": "/etc/aios-orchestrator-gateway/inbound.token"
}
+17
View File
@@ -0,0 +1,17 @@
{
"active_dir": "/data/aios-gitops/workspace/agents/commands/active",
"completed_dir": "/data/aios-gitops/workspace/agents/commands/completed",
"dead_letter_dir": "/data/aios-gitops/workspace/agents/commands/dead-letter",
"database_path": "/var/lib/aios-codex-watcher/state.sqlite3",
"audit_log_path": "/var/log/aios-codex-watcher/audit.jsonl",
"gateway_endpoint": "http://127.0.0.1:18920/v1/orchestrator/events",
"gateway_token_file": "/etc/aios-orchestrator-gateway/inbound.token",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"lease_seconds": 60,
"retry_seconds": 10,
"max_attempts": 5,
"poll_seconds": 5,
"executor_path": null,
"executor_args": [],
"executor_timeout_seconds": 1800
}
+17
View File
@@ -0,0 +1,17 @@
{
"project_key": "v2r1-personal-weixin-production",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"gateway_event_url": "http://127.0.0.1:18920/v1/orchestrator/events",
"gateway_token_file": "/etc/aios-orchestrator-gateway/inbound.token",
"gateway_database_path": "/var/lib/aios-orchestrator-gateway/state.sqlite3",
"probe_clean_base_commit": "c3984ca2b4388875d2875b4fdfdf95a2984f6ab3",
"central_receipt_path": "/run/aios-health/central-gpt.json",
"codex_receipt_path": "/run/aios-health/codex.json",
"codex_command_queue": "/data/aios-gitops/workspace/agents/commands/active",
"codex_database_path": "/var/lib/aios-codex-watcher/state.sqlite3",
"codex_audit_log": "/var/log/aios-codex-watcher/audit.jsonl",
"central_timeout_seconds": 90,
"codex_timeout_seconds": 90,
"cycle_timeout_seconds": 180,
"poll_interval_seconds": 1
}
@@ -0,0 +1,49 @@
{
"host": "127.0.0.1",
"port": 18920,
"database_path": "/var/lib/aios-orchestrator-gateway/state.sqlite3",
"event_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/events",
"decision_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/decisions",
"dispatch_outbox_dir": "/var/lib/aios-orchestrator-gateway/outbox",
"openclaw_sync_dir": "/var/lib/aios-orchestrator-gateway/openclaw-sync",
"command_queue_dir": "/var/lib/aios-orchestrator-gateway/commands",
"inbound_token_file": "/etc/aios-orchestrator-gateway/inbound.token",
"callback_token_file": "/etc/aios-orchestrator-gateway/callback.token",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"workspace_agent": {
"trigger_id": "agtch_REPLACE_ME",
"access_token_file": "/etc/aios-orchestrator-gateway/workspace-agent.token"
},
"responses_fallback": {
"api_key_file": "/etc/aios-orchestrator-gateway/openai-api.key",
"model": "REPLACE_WITH_APPROVED_MODEL"
},
"polling": {
"interval_seconds": 10,
"summary_interval_seconds": 300
},
"loop_controls": {
"callback_timeout_seconds": 900,
"lease_seconds": 60,
"retry_base_seconds": 10,
"max_retries": 5,
"max_same_blocker_retries": 3,
"max_hops_per_gate": 20
},
"openclaw_sync": {
"endpoint": null,
"token_file": null
},
"project_key": "v2r1-personal-weixin-production",
"git_poll": {
"enabled": true,
"bare_repo": "/data/aios-gitops/repo.git",
"ref_prefixes": [
"refs/heads/main",
"refs/heads/task/governance/",
"refs/heads/patch/",
"refs/heads/result/"
],
"interval_seconds": 30
}
}
-63
View File
@@ -1,63 +0,0 @@
{
"schema_version": 2,
"automatic": [
"build",
"test",
"artifact",
"canary",
"evidence"
],
"owner": [
"signed_release_plan"
],
"forbidden": [
"force_push",
"tag_rewrite",
"direct_main_push",
"baseline_mutation",
"registry_mutation"
],
"runtime_deployment": {
"approval_action": "signed_release_plan",
"required_capability": "runtime_switch",
"tag_operations_allowed": false,
"baseline_mutation_allowed": false,
"registry_mutation_allowed": false,
"writer_default": false,
"rollback_required": true
},
"tag_release": {
"approval_action": "signed_release_plan",
"required_capability": "tag_publish",
"separate_controller": true,
"duplicate_conflict": "fail_closed",
"rewrite_allowed": false,
"force_push_allowed": false
},
"baseline_registry_finalize": {
"approval_action": "signed_release_plan",
"required_capability": "baseline_registry_finalize",
"separate_controller": true,
"remote_tag_verification_required": true,
"gitea_api_verification_required": true,
"registry_baseline_consistency_required": true
},
"approval": {
"algorithm": "Ed25519",
"replay_protection": true,
"revocation_fail_closed": true,
"key_rotation_supported": true,
"capabilities": [
"runtime_switch",
"tag_publish",
"baseline_registry_finalize"
]
},
"execution": {
"environment_variable": "AIOS_SILENT_RELEASE_EXECUTION_ENABLED",
"default_enabled": false,
"shadow_side_effects_allowed": false,
"durable_saga_required": true,
"pre_switch_revalidation_required": true
}
}
-59
View File
@@ -1,59 +0,0 @@
version: 1
policy_id: gitops-isolated-worktree-governance-20260714T232948UTC8
runtime_identity:
user: aiman
group: gitops
umask: "0002"
prohibit_root_workspace_writes: true
repository:
source_worktree: /data/aios-gitops/workspace
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
remote: origin
worktrees:
controller: /data/aios-gitops/worktrees/controller
task_pattern: /data/aios-gitops/worktrees/{agent}/{task_id}
allowed_agents:
- openclaw
- chatgpt
- controller
require_clean_start: true
remove_after_successful_push: true
retain_on_failure: true
permissions:
owner: aiman
group: gitops
directory_mode: "2775"
file_mode: "0664"
default_acl:
user:aiman: rwx
group:gitops: rwx
apply_to:
- /data/aios-gitops
- /data/aios-gitops/worktrees
preflight:
required_user: aiman
required_group: gitops
required_umask: "0002"
require_writable_git_dir: true
require_writable_task_dir: true
require_writable_result_dir: true
reject_shared_source_worktree: true
reject_existing_git_lock: true
notifications:
owner_on_received: required
owner_on_terminal: required
preferred_channel: weixin
redact_secrets: true
rollback:
preserve_source_worktree: true
remove_created_worktrees: true
restore_service_dropins: true
restore_service_state: true
@@ -1,14 +0,0 @@
# Install as a systemd drop-in for GitOps/OpenClaw writer services.
# Example: /etc/systemd/system/<service>.service.d/10-runtime-identity.conf
[Service]
User=aiman
Group=gitops
SupplementaryGroups=gitops
UMask=0002
Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
Environment=AIOS_BARE_REPO=/data/aios-gitops/repo.git
Environment=AIOS_WORKTREE_ROOT=/data/aios-gitops/worktrees
Environment=AIOS_RUNTIME_USER=aiman
Environment=AIOS_RUNTIME_GROUP=gitops
Environment=AIOS_RUNTIME_UMASK=0002
@@ -1,27 +0,0 @@
# AIOS V2R1C2 Unified Portal production metadata.
# No secret values belong in this file; runtime integrations use references.
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18920
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-production
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-production/run-provenance.sqlite3
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-production/external-task-truth.sqlite3
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-production/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
AIOS_TARGET_VERSION=V2R1C2
AIOS_BASELINE_VERSION=V2R1C1
AIOS_PREVIOUS_VERSION=V2R1C1
AIOS_RELEASE_STATUS=PRODUCTION
AIOS_RELEASE_SOURCE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_TAG=V2R1C2
AIOS_RELEASE_EVIDENCE_SHA256=2e27bb73b8e6297bd3077238f73a96d697fc3c3611c601c4dc9357e5fdcd8eee
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
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
@@ -1,44 +0,0 @@
[Unit]
Description=AIOS V2R1C2 Unified Portal Production
After=network-online.target
Wants=network-online.target
Conflicts=aios-orchestrator-gateway.service
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
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-production.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18920"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_TAG" = "V2R1C2"
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-production
StateDirectory=aios-v2r1c2-production
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
+1 -2
View File
@@ -11,10 +11,9 @@ Environment=AIOS_GITOPS_WORKSPACE=/data/aios-gitops/workspace
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
Environment=AIOS_GITOPS_HOST=127.0.0.1
Environment=AIOS_GITOPS_PORT=18890
EnvironmentFile=-/etc/aios-gitops/orchestrator-callback.env
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
Restart=always
RestartSec=3
+137
View File
@@ -0,0 +1,137 @@
# AIOS GPT + one-executor automation quorum
## Authority and release boundary
The Gateway is the only authoritative state writer. It must hold the SQLite writer lease before changing task state. A model response, service process, configured credential, or agent presence does not establish health. Release remains paused until all live acceptance rows are backed by logs and exit codes.
Minimum quorum:
```text
central_gpt=HEALTHY AND (openclaw=HEALTHY OR codex=HEALTHY)
```
`UNKNOWN` never counts as healthy. If one executor is proven healthy, an unhealthy or unknown standby is P3 telemetry only and cannot block the active gate. If no executor is proven healthy, the controller blocks. Central GPT `UNKNOWN` or `UNHEALTHY` always blocks.
## Live health receipts
The controller reads three root-owned metadata-only JSON receipts from `/run/aios-health`. Files must be regular files, must not be symlinks, and neither files nor parent directories may be group/world writable. The controller rejects fields named `secret`, `token`, `password`, `api_key`, `authorization`, `credential`, `private_key`, `environment`, `env`, `argv`, `stdout`, or `stderr` at any depth.
A receipt claiming `HEALTHY` must contain:
```json
{
"subject": "codex",
"health": "HEALTHY",
"live_probe_succeeded": true,
"observed_at_epoch": 1784347200
}
```
The central GPT receipt additionally requires either `workspace_agent.validated_live=true` or `responses_api.validated_live=true`. Responses fallback is allowed only after a real Responses decision probe succeeded; credential/configuration presence is insufficient.
The controller and probes use only the fixed loopback endpoint
`http://127.0.0.1:18920/v1/orchestrator/events`. The bearer is read from
`/etc/aios-orchestrator-gateway/inbound.token`; the file must be absolute,
root-owned, regular, non-symlinked, no wider than `0640`, nonempty, and contain
no CR/LF. It is never accepted through argv, environment, logs, receipts, or
Git. HTTP `401`, `403`, `409`, and `422` are terminal fail-closed results for a
probe attempt.
The authenticated metadata query
`GET /v1/orchestrator/events/<event_id>` returns only the event ID, public
status, selected central channel, whether the decision was applied, and the
resulting state version. It never returns the submitted event, model output,
credential, or work-unit instruction.
## Durable behavior
SQLite persists ingress event digests, optimistic task state versions, leases, checkpoints, lifecycle events, effect IDs, outbox messages, blocker counts, observed Git refs, and project summaries. Reusing an event/effect/outbox ID with different content fails closed. An expired lease may be reclaimed; a live lease cannot be stolen.
On active-executor failure, a healthy standby receives the same `contract_commit`, `event_id`, state version, and checkpoint. The former executor is recorded as `INTERRUPTED_AGENT_FAILOVER`. Unique effect IDs prevent replay of completed side effects.
The Gateway persists these events even when OpenClaw is offline:
```text
TASK_DISPATCHED
TASK_STARTED
TASK_ENDED
DECISION_APPLIED
PROJECT_SUMMARY
PROJECT_COMPLETED
```
OpenClaw mirroring is optional and uses the durable outbox with bounded retry and dead-letter. HTTP ingress failure spools a metadata-only file for polling; the configured local Git refs are also polled and changes missed by webhooks become idempotent events. Repeated blockers beyond the configured threshold enter `DEAD_LETTER`.
## Install, verify, and rollback
Installation is versioned and does not overwrite an existing release:
```bash
sudo scripts/install_automation_quorum_controller.sh "$PWD" 20260718.1
sudo systemctl enable --now aios-automation-quorum-controller.timer
sudo systemctl enable --now aios-central-gpt-health-probe.timer
sudo systemctl enable --now aios-codex-executor-health-probe.timer
```
Run deterministic verification before enablement:
```bash
scripts/verify_automation_quorum_controller.sh "$PWD"
```
The two root probes are fixed, bounded, side-effect-free programs. They write
receipts atomically with mode `0600` below the root-owned `0750`
`/run/aios-health`. The Codex probe may write only one `acceptance_probe`
command into the controlled watcher queue; it cannot accept arbitrary command
text from configuration.
Run the strict two-cycle closure only after both services are installed from an
immutable committed release:
```bash
sudo /opt/aios-automation-quorum/current/bin/a0-unattended-loop-probe \
--config /etc/aios-automation-quorum/health-probes.json
```
Each cycle requires a live central decision that dispatches the named Codex
acceptance work unit through the Gateway. The verifier then requires exactly
one claim/start/execute/end/post marker, both lifecycle events in the Gateway,
and a persisted `PROJECT_SUMMARY`. Directly queuing a command cannot satisfy
this two-cycle closure.
Rollback accepts only an existing non-symlink directory below `/opt/aios-automation-quorum/releases`. Repeating rollback to the current target is a no-op:
```bash
sudo scripts/rollback_automation_quorum_controller.sh \
/opt/aios-automation-quorum/releases/20260718.0
```
## Live acceptance matrix
Each row requires captured logs, timestamps, exit codes, committed OIDs, Gitleaks output, and where applicable live Gateway DB evidence. Static unit tests are necessary but cannot close the gate.
1. GPT + OpenClaw healthy, Codex stopped: two unattended cycles.
2. GPT + Codex healthy, OpenClaw stopped: two unattended cycles.
3. Both executors healthy: OpenClaw gets repetitive infrastructure; Codex gets complex code/security.
4. Active stops mid-task: standby continues from checkpoint with no duplicate effect.
5. Standby alone stops: active flow continues and no repair gate is created.
6. GPT stops: execution blocks even with two healthy executors.
7. GPT healthy and both executors stop: `BLOCKED_NO_HEALTHY_EXECUTOR`.
8. `UNKNOWN` is never counted as healthy.
9. HTTP ingress failure recovers through file polling.
10. Missed webhook recovers through Git ref polling.
11. Gateway restart restores events, leases, and outbox.
12. Repeated event ID does not create a second dispatch; changed payload dead-letters.
13. Repeated blocker beyond threshold dead-letters.
14. Workspace Agent failure falls back only with a live-validated Responses channel.
15. OpenClaw offline: Codex lifecycle and project summary remain complete in Gateway.
16. Codex offline: OpenClaw lifecycle and project summary remain complete in Gateway.
Until all rows pass against the live service, retain:
```text
AUTOMATION_CLOSED=NOT_STARTED
BASELINE=V1R4C6
RELEASE=paused
PRODUCTION_RELEASE=NOT_STARTED
```
@@ -1,268 +0,0 @@
# AIOS Silent Release Controller Design Report
状态:隔离分支实现;生产 mutation 禁止
目标:自动准备 → 自动验证 → 单次 Owner 签名授权 → 分控制器自动执行与验收
## 1. 当前痛点
现有流程需要多轮人工传递 commit、tree、artifact、manifest、测试、canary、drop-in
和回滚证据。证据在不同消息里重复抄写,容易出现:
- 构建后 artifact、manifest 或 policy 已变化,但旧批准仍被使用;
- Git、Gitea、systemd 与回滚结果由人工拼接,缺少同一一致性快照;
- runtime switch、tag、Baseline、Registry 的授权边界不清;
- 重启后只剩工作树内临时 JSON,无法可靠恢复;
- approval replay、并发 writer、key revoke 和 secret inheritance 缺少统一防线。
## 2. 新架构
```text
Build / Test / Canary
|
v
ReleaseCandidate + DeploymentPlan
|
v
ReleaseAcceptanceEngine
|
v
READY_FOR_APPROVAL
|
v
SignedReleasePlanApproval (Ed25519)
|
+--> runtime_switch capability
| DeploymentController
|
+--> tag_publish capability
| TagController
|
+--> baseline_registry_finalize capability
BaselineRegistryFinalizeController
Durable ReleaseSagaStore + CapabilityLedger + Event Journal
贯穿三个控制器;控制器互相不拥有对方的 mutation API。
```
一次 Owner 批准是同一个不可变 release plan 的签名,不是一个万能执行入口。三个 capability
分别消费、分别留痕、分别幂等恢复。`force_push`、`tag_rewrite`、`direct_main_push`
永远不是可签发 capability。
## 3. 状态机
```text
PREPARING
-> VERIFYING
-> READY_FOR_APPROVAL
-> APPROVED
-> PRE_SWITCH_REVALIDATING
-> SWITCH_PENDING
-> SWITCHED
-> RUNTIME_VERIFYING
-> TAG_PENDING
-> TAG_VERIFIED
-> BASELINE_REGISTRY_PENDING
-> COMPLETED
任一可恢复故障 -> RECONCILIATION_REQUIRED
对象冲突 -> MANUAL_OWNER_REVIEW_REQUIRED
切换后失败 -> ROLLBACK_PENDING -> ROLLED_BACK
```
状态快照使用 revision compare-and-set;事件同时写入 append-only journal。writer 崩溃后根据
状态确定唯一恢复动作。并发控制器中只有一个 CAS winner。
## 4. 数据模型
### ReleaseCandidate
```text
candidate_id
source_commit
tree_oid
artifact_sha256
manifest_sha256
tests
canary
rollback_ready
git_consistency
gitea_consistency
approval_state
```
### DeploymentPlan
```text
service
release_path / release_root
dropin_path / dropin_sha256
rollback_path / rollback_sha256
source_commit
artifact_sha256
manifest_sha256
policy_sha256
health_contract
sha256
```
### SignedReleasePlanApproval
```text
release_plan_id
candidate_id
source_commit
tree_oid
artifact_sha256
manifest_sha256
deployment_plan_sha256
policy_sha256
approval_plan_id
evidence_closure_oid
owner_approval_record_oid
capabilities
expires_at
nonce
key_id
signature
fingerprint
```
签名内容使用 canonical JSON,`signature` 不参与被签名字段。验证包括 Ed25519 签名、过期时间、
公钥状态、approval revoke、candidate/plan/policy 逐字段绑定和 capability allowlist。
## 5. API
```text
POST /createReleaseCandidate
GET /getReleaseCandidate?candidate_id=...
GET /getApprovalEnvelope?candidate_id=...
POST /submitOwnerApproval
POST /startDeployment
GET /getReleaseSaga?candidate_id=...
```
写接口要求 Bearer scope。`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` 是默认值;此时可以
持久化 candidate、生成 approval envelope、保存经验证的 Owner approval 和读取 Saga,
但 `/startDeployment` 只返回 `SHADOW_MODE` 与空 `side_effects`。
## 6. PreSwitchValidator
切换前重新收集并比较:
- source commit 与 tree;
- artifact、manifest、policy 和 deployment plan SHA-256;
- rollback readiness;
- local、authoritative bare 与 Gitea refs;
- repository identity;
- task writer/production writer 边界;
- health contract。
任一变化都抛出:
```text
APPROVAL_STALE_REAPPROVAL_REQUIRED
```
旧批准不会被“最佳努力”继续使用。
## 7. systemd 防线
- service 与 drop-in 均为显式 allowlist;
- release 与 drop-in resolved path 必须位于 release root 内;
- 拒绝普通文件被替换为 symlink,拒绝 symlink escape;
- deployment lock 覆盖 switch、restart 和首次 health check;
- restart 前后记录 MainPID 与 ActiveEnterTimestampMonotonic;
- artifact、drop-in、rollback 和 manifest 均按 plan hash 复验;
- 子进程只接收固定 allowlist 环境,不继承 token、password、credential、askpass、
auth socket、`LD_PRELOAD`、`PYTHONPATH` 或数据库连接串。
## 8. TagController 分离
TagController 必须独立完成 approval、execute、verify,并留下:
```text
evidence_closure_oid
approval_plan_id
repository_identity
remote_tag_oid
remote_peeled_target_oid
gitea_api_tag_oid
gitea_listing_visible
```
同名 tag 的 tag OID 或 peeled target 不同,进入
`MANUAL_OWNER_REVIEW_REQUIRED`;自动流程不 push、不删除、不重写、不 force push。
## 9. Baseline / Registry 门禁
只有以下全部一致时,`baseline_registry_finalize` capability 才能完成:
```text
Registry = Baseline = remote tag = Gitea API = expected target
```
UNKNOWN、API 不可达、repository identity 不符或 listing 不可见均 fail closed。
## 10. Policy as Code
```text
automatic:
build, test, artifact, canary, evidence
owner:
signed_release_plan
capabilities:
runtime_switch
tag_publish
baseline_registry_finalize
forbidden:
force_push
tag_rewrite
direct_main_push
baseline_mutation
registry_mutation
```
最后两个 forbidden 项指“脱离受治理 finalize controller 的直接 mutation”。
## 11. Security model
- Ed25519 公钥 ring 支持 rotation 和 revoke,私钥不进入 runtime;
- approval plan 可撤销,过期、未知 key、错误签名均 fail closed;
- nonce 与 capability operation 持久化,重复相同请求幂等,不同 fingerprint 重放拒绝;
- Saga snapshot 原子写、journal append-only、CAS 防并发 writer;
- credential 只通过受控 reference 使用,不进入 Git、API 响应或异常;
- 子进程环境从空集合构建,不复制 `os.environ`;
- Shadow 模式默认开启,execution adapter 缺失时不能降级执行;
- runtime、tag、Baseline/Registry 分属三个能力最小化控制器。
## 12. Migration plan
1. Shadow:生成 candidate、plan、acceptance、approval package,仅比较现有人工结果。
2. Signed approval:接入 Owner 公钥 ring、撤销源和审计 record;仍不接生产 executor。
3. Canary execution:使用非生产 service/release root 验证锁、PID、health、崩溃恢复和回滚。
4. Controlled production switch:另行 Owner 授权启用 execution flag,只开放 runtime capability。
5. Tag/finalize:分别完成 Gitea 与 Registry/Baseline 生产验收后再启用对应 capability。
每个阶段均可退回上一个阶段;不开启下一能力不会影响已经启用的控制器。
## 13. Rollback plan
- switch 前保存旧 symlink target、PID、启动时间、artifact 与 plan fingerprint;
- restart 或 health 任一失败进入 `ROLLBACK_PENDING`;
- 原子恢复旧 target,只 restart 指定 allowlist service;
- 重新验证旧 runtime commit、writer=false 与 repository identity;
- 成功进入 `ROLLED_BACK`,失败进入 `MANUAL_OWNER_REVIEW_REQUIRED`;
- rollback 不修改 Git main、production tag、Baseline 或 Registry;
- 保留脱敏 journal、approval fingerprint 和失败证据。
## 14. 测试与外部依赖边界
自动测试覆盖签名伪造、字段篡改、过期/revoke/key rotation、nonce replay、CAS race、
崩溃恢复、artifact/policy/plan mutation、tag conflict、secret leakage、runtime without
`.git`、rollback 和 shadow no-side-effects。
本隔离实现不创建生产 KMS/key、不安装 systemd unit、不部署 Action runtime、不创建 tag,
也不修改 Baseline/Registry。上述动作必须在单独生产批准中执行。
@@ -1,68 +0,0 @@
# Gitea Tag Publication Atomicity
Release publication is a fail-closed Saga bound to one immutable governance
fingerprint:
- `version`
- `target_oid`
- `evidence_closure_oid`
- `approval_plan_id`
The normal progression is:
`PENDING` → `LOCAL_TAG_CREATED` → `REMOTE_PUSH_CONFIRMED` →
`GITEA_API_CONFIRMED` → `BASELINE_UPDATE_PENDING` → `BASELINE_UPDATED` →
`PRODUCTION_DEPLOYED`
An unavailable remote uses `REMOTE_PUSH_PENDING` or
`RECONCILIATION_REQUIRED`. A same-name tag object mismatch, peeled-target
mismatch, changed governance fingerprint, or immutable deployment-evidence
conflict enters `MANUAL_OWNER_REVIEW_REQUIRED`. That state blocks automatic
push and reconciliation; recovery never deletes, rewrites, or force-pushes a
tag.
## Legacy state migration
Readers normalize the historical `TAG_CREATED_LOCAL` value to
`LOCAL_TAG_CREATED` in memory. Read-only status responses therefore expose only
the canonical value. The next governed Saga write persists the canonical name;
reading legacy state alone does not mutate the tag, Baseline, Registry, or Saga
file. Any unrecognized persisted state fails closed as
`RECONCILIATION_REQUIRED`.
`BASELINE_UPDATE_PENDING` is persisted before either `baseline/current.json` or
`versions/<version>.json` changes. Recovery revalidates the governance
fingerprint, evidence lineage, remote annotated tag, peeled target, Gitea API,
listing, and repository identity before idempotently completing matching
partial records. A different tag or target enters
`MANUAL_OWNER_REVIEW_REQUIRED`.
## Durable state
Production writers set `AIOS_GITOPS_PUBLICATION_STATE_DIR` to storage outside
their disposable worktree. Recreating a writer must reuse that directory.
Worktree JSON is not the authoritative Saga store.
## Deployment gate
`mark_production_deployed` performs fresh Git remote, peeled-target, Gitea ref,
Gitea tag-object, listing, repository-identity, evidence-lineage, Registry, and
Baseline checks. Deployment is allowed only when:
`Registry = Baseline = remote tag = Gitea API = expected target`
Deployment evidence must be an immutable `sha256:<64 hex>` or `git:<40 hex>`
identifier.
## Read-only acceptance
- `getTagPublicationStatus` reads durable Saga state.
- `getReleaseConsistency` compares every release authority.
- `runReleaseAcceptance` returns `accepted: true` only for `PASS`.
Unavailable evidence returns `UNKNOWN` with `fail_closed: true`; it is never
treated as acceptance.
The CLI, HTTP Action, and release script all enter the same publisher core for
evidence-lineage verification. The release script stages Registry and Baseline
records only; durable Saga state remains outside Git.
+3 -206
View File
@@ -13,27 +13,6 @@ components:
scheme: bearer
bearerFormat: opaque-token
schemas:
PublicationState:
type: string
description: Canonical durable publication state. Legacy TAG_CREATED_LOCAL is normalized on read and is never emitted by new writes.
enum:
- PENDING
- LOCAL_TAG_CREATED
- REMOTE_PUSH_PENDING
- REMOTE_PUSH_CONFIRMED
- GITEA_API_CONFIRMED
- BASELINE_UPDATE_PENDING
- BASELINE_UPDATED
- PRODUCTION_DEPLOYED
- RECONCILIATION_REQUIRED
- MANUAL_OWNER_REVIEW_REQUIRED
PublicationStatus:
type: object
required: [version, state]
properties:
version: {type: string}
state: {$ref: '#/components/schemas/PublicationState'}
fail_closed: {type: boolean}
BranchRequest:
type: object
required: [branch]
@@ -71,20 +50,9 @@ components:
message: {type: string}
TagRequest:
type: object
required: [version, target_oid, evidence_closure_oid, approval_plan_id]
required: [version]
properties:
version: {type: string, example: V1R1C1}
target_oid:
type: string
pattern: '^[0-9a-fA-F]{40}$'
description: Full approved commit OID. The tag is created explicitly at this commit, never implicitly at workspace HEAD.
evidence_closure_oid:
type: string
pattern: '^[0-9a-fA-F]{40}$'
description: Required evidence closure commit. target_oid must be its ancestor.
approval_plan_id:
type: string
description: Audited owner-approved release or tag plan identifier.
message: {type: string}
execute:
type: boolean
@@ -135,142 +103,7 @@ components:
properties:
text: {type: string}
branch: {type: string}
DeploymentPlan:
type: object
required: [service, release_path, release_root, dropin_path, dropin_sha256, rollback_path, rollback_sha256, source_commit, artifact_sha256, manifest_sha256, policy_sha256, health_contract]
properties:
service: {type: string}
release_path: {type: string}
release_root: {type: string}
dropin_path: {type: string}
dropin_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
rollback_path: {type: string}
rollback_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
health_contract: {type: object, additionalProperties: true}
ReleaseCandidateInput:
type: object
required: [candidate_id, source_commit, tree_oid, artifact_sha256, manifest_sha256]
properties:
candidate_id: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$'}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
tests: {type: object}
canary: {type: object}
rollback_ready: {type: boolean}
git_consistency: {type: object}
gitea_consistency: {type: object}
deployment_plan: {$ref: '#/components/schemas/DeploymentPlan'}
SignedReleasePlanApproval:
type: object
required: [release_plan_id, candidate_id, source_commit, tree_oid, artifact_sha256, manifest_sha256, deployment_plan_sha256, policy_sha256, approval_plan_id, evidence_closure_oid, owner_approval_record_oid, capabilities, expires_at, nonce, key_id, signature]
properties:
release_plan_id: {type: string}
candidate_id: {type: string}
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
deployment_plan_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
approval_plan_id: {type: string}
evidence_closure_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
owner_approval_record_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
capabilities:
type: array
uniqueItems: true
items:
type: string
enum: [runtime_switch, tag_publish, baseline_registry_finalize]
expires_at: {type: string, format: date-time}
nonce: {type: string, minLength: 16}
key_id: {type: string}
signature: {type: string, format: byte, writeOnly: true}
StartDeploymentRequest:
type: object
required: [candidate_id, approval]
properties:
candidate_id: {type: string}
approval: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
paths:
/createReleaseCandidate:
post:
operationId: createReleaseCandidate
summary: Persist an immutable release candidate and deployment plan; shadow-safe
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReleaseCandidateInput'}
responses:
'200': {description: Candidate created or idempotently returned}
'400': {description: Invalid or conflicting candidate evidence}
'401': {description: Unauthorized}
x-openai-isConsequential: true
/getReleaseCandidate:
get:
operationId: getReleaseCandidate
summary: Read a durable release candidate
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Candidate}
'404': {description: Candidate not found}
/getApprovalEnvelope:
get:
operationId: getApprovalEnvelope
summary: Generate the immutable owner-signature payload
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Approval envelope fingerprint inputs}
'404': {description: Candidate not found}
/submitOwnerApproval:
post:
operationId: submitOwnerApproval
summary: Verify and durably store a signed owner release plan
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
responses:
'200': {description: Signature verified and approval stored}
'400': {description: Invalid approval}
'403': {description: Signature, key, expiry, or revocation failure}
'409': {description: Conflicting release plan}
x-openai-isConsequential: true
/startDeployment:
post:
operationId: startDeployment
summary: Start the approved Saga; default shadow mode returns no side effects
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/StartDeploymentRequest'}
responses:
'200': {description: Shadow result or deployment Saga result}
'403': {description: Execution disabled or approval invalid}
'409': {description: Saga conflict}
x-openai-isConsequential: true
/getReleaseSaga:
get:
operationId: getReleaseSaga
summary: Read durable Saga state and event journal
parameters:
- {in: query, name: candidate_id, required: true, schema: {type: string}}
responses:
'200': {description: Saga snapshot}
'404': {description: Saga not found}
/api/base:
get:
operationId: getBaseline
@@ -366,39 +199,6 @@ paths:
schema: {$ref: '#/components/schemas/CommitRequest'}
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
x-openai-isConsequential: true
/api/tag-publication-status:
get:
operationId: getTagPublicationStatus
summary: Read durable publication Saga status without mutation
parameters:
- {in: query, name: version, required: true, schema: {type: string, pattern: '^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$'}}
responses:
'200':
description: Canonical publication state or fail-closed UNKNOWN
content:
application/json:
schema: {$ref: '#/components/schemas/PublicationStatus'}
'400': {description: Version required}
/api/release-consistency:
get:
operationId: getReleaseConsistency
summary: Compare Registry, Baseline, remote tag, Gitea API, listing, repository identity, and expected target
parameters:
- {in: query, name: version, required: true, schema: {type: string}}
- {in: query, name: target_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
- {in: query, name: evidence_closure_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
- {in: query, name: approval_plan_id, required: true, schema: {type: string}}
responses: {'200': {description: PASS, FAIL, or fail-closed UNKNOWN}}
/api/release-acceptance:
get:
operationId: runReleaseAcceptance
summary: Run read-only release acceptance; UNKNOWN is not accepted
parameters:
- {in: query, name: version, required: true, schema: {type: string}}
- {in: query, name: target_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
- {in: query, name: evidence_closure_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
- {in: query, name: approval_plan_id, required: true, schema: {type: string}}
responses: {'200': {description: Acceptance result with accepted boolean}}
/api/tag:
post:
operationId: requestOrCreateTag
@@ -409,7 +209,7 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/TagRequest'}
responses: {'200': {description: Gitea-confirmed tag and fail-closed baseline result}, '400': {description: Missing governance evidence or invalid OID}, '403': {description: Forbidden}, '409': {description: Existing tag target conflict or evidence lineage mismatch}, '502': {description: Gitea reconciliation required}, '503': {description: Remote push pending}}
responses: {'200': {description: Tag request committed or tag created}, '403': {description: Forbidden}}
x-openai-isConsequential: true
/api/rollback:
post:
@@ -445,7 +245,4 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/NLRequest'}
responses:
'200': {description: Safe GitOps plan}
'401': {description: Unauthorized}
x-openai-isConsequential: true
responses: {'
-56
View File
@@ -1,56 +0,0 @@
# V2R1C1 中央 Baseline 治理收口
## 收口范围
本次变更只登记已经完成生产验证并上线的 `V2R1C1`。它新增中央版本 Manifest、把中央 Baseline Pointer 从 `V1R4C6` 切换为 `V2R1C1`,并建立 Release、Companion、Artifact 与最终生产 Evidence 的追溯关系。
本次不修改产品实现,不移动或重建 `V2R1C1` Tag,不改变 Release OID,不重新部署生产,不切换生产流量,也不重写历史证据。
## 权威标识
- Release:`release/AIOS-V2R1C1-20260721`
- Release OID:`4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`
- Tag / Tag OID:`V2R1C1` / `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`
- SemVer:`2.1.0-rc.1`
- OpenClaw Companion:`2.1.0-rc.4`
- Companion Source OID:`83f8f878c7da5dd8263f2af2efa80750492e5cb3`
- Companion OID:`12d1c13170e0c5a691d67a9408e739e7a17064a3`
- Artifact SHA256:`aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
- Contract:`aios.openclaw.command.v2` / `Ed25519`
- Evidence Root:`/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
- Final Audit Manifest SHA256:`2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`
- Production Status:`PRODUCTION_DEPLOYED`
## 治理路径
- Governance Branch:`patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`
- Base Ref:`refs/heads/main`
- Base OID:`3cf9958cb94614449e093639cbf902216d73447f`
- Manifest Commit:`a56caf46f6bc335ec2395b674ead4b2a427c5edd`
- Baseline Commit:`6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`
- Reviewer:`gpt-5.3-codex-spark validator and docs_reporter`
- Review:`PASS`(`2026-07-21T14:23:00Z`)
## 命令与退出码
| 阶段 | 命令 | 开始 UTC | 结束 UTC | Exit Code |
| --- | --- | --- | --- | ---: |
| 中央只读核对 | `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
| 创建治理分支 | `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
| 核心元数据验证 | `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
完整机读记录位于 `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`,治理运行证据位于 `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`。
## 元数据回滚
若中央 Registry 解析或治理服务异常,保留 `versions/V2R1C1.json`,通过新的受审治理提交把 `baseline/current.json` 和 `VERSION` 临时恢复到 `V1R4C6`。该操作不得回滚生产系统、移动 Tag、修改 Release OID 或重写历史。
## 范围证明
```text
product_code_changed=false
tag_changed=false
production_redeployed=false
production_traffic_changed=false
secret_material_recorded=false
```
@@ -1,35 +0,0 @@
# V2R1C2 Production Release
- Release Run ID: `AIOS-V2R1C2-PRODUCTION-20260722T011836Z`
- Previous production baseline: `V2R1C1`
- Tag: `V2R1C2`
- Tag object: `a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36`
- Approved target: `3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24`
- Evidence closure: `8cdca463aff9707edee00e20a3b56884130f6978`
- Initial registry status: `TAG_CREATED_NOT_PROMOTED`
- Final status: `PRODUCTION_DEPLOYED`
- Production service: `aios-v2r1c2-production.service`
- Production port: `18920`
- Production acceptance SHA256: `a6c35e12cab7907ab6bcfd37d781b3d216f05a5ae002aa54a3112c70f43b991a`
- Production online smoke SHA256: `fe9c6964e440f8871f2eb225f0132f70c634923389f7b8dfa85f615b100980a4`
- Production health SHA256: `ee8084e8b57d188dc0039a276f4f3f1ed7025ff58ce91b111cc765627eb52d4c`
- Live evidence directory: `/var/lib/aios-gitops/acceptance/AIOS-V2R1C2-PRODUCTION-20260722T011836Z`
- Evidence hash index: `/var/lib/aios-gitops/acceptance/AIOS-V2R1C2-PRODUCTION-20260722T011836Z/production-evidence.sha256`
## Production gates
Promotion may proceed only when the 18921 candidate remains healthy, the committed evidence checksum file verifies without drift, the 18920 baseline is healthy, required secret references are available without exposing secret values, and the V2R1C1 rollback service/configuration remains intact.
The obsolete release-plan `DECLARED` rows are not authoritative for this run. The production gate must explicitly select `AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722` and require its three required stages to be `SUCCEEDED`; an unqualified aggregate across stale plan rows fails closed.
## Synthetic WeChat scope
WeChat-facing acceptance for this release used synthetic/mock receipts and transcripts only. It did not impersonate a real user and does not claim a real-user WeChat session. The relevant committed evidence entries are `wechat_simulated_receipts` and `wechat_simulated_transcript`; the production online smoke also records `simulation: true` and `real_user_impersonated: false`.
## Rollback rule
If Health, API, UI, Role Matrix, Tenant Isolation, Runtime, or Audit verification fails after the 18920 switch, stop the V2R1C2 production unit and restart the preserved V2R1C1 18920 service. Do not delete or rewrite either release tag or rollback asset.
## Result
The production switch completed at `2026-07-22T01:40:51Z`. Full acceptance and the production online smoke passed, including Health, API, UI, Role Matrix, Tenant Isolation, Runtime, Audit and the selected takeover release gate. The previous `aios-orchestrator-gateway.service` unit, configuration and Release Run backup remain available for rollback.
@@ -0,0 +1,65 @@
# A0 callback transport and pending-decision recovery
This runbook separates code acceptance from live callback acceptance. Passing
unit tests does not prove that a cloud Workspace Agent can reach the installed
tool endpoint.
## Transport configuration
Choose exactly one mode in the owner-managed remote-tool configuration:
- `remote_https_action`: `endpoint` must be the real public HTTPS Action URL.
- `secure_mcp_tunnel`: `endpoint` must exactly match an entry in
`approved_mcp_tunnel_endpoints`.
The configuration stores only `access_secret_ref` and `callback_secret_ref`.
It must not contain resolved credentials. Startup rejects HTTP, loopback,
unspecified/private literal addresses, URL credentials, query/fragment data,
unapproved tunnel endpoints, and known placeholder markers.
Generate the cloud-facing manifest only after owner configuration exists:
```bash
PYTHONPATH=/opt/aios-orchestrator-gateway/current \
python3 /opt/aios-orchestrator-gateway/current/scripts/generate_orchestrator_tool_manifest.py \
--transport-config /etc/aios-orchestrator-gateway/remote-tool.json \
--output /var/lib/aios-orchestrator-gateway/orchestrator-tool-manifest.json
```
The generated Action manifest exposes only
`submit_orchestration_decision`. The MCP manifest lists the same single tool
with write-side-effect and idempotency annotations.
## Recovery
Plan mode is read-only:
```bash
PYTHONPATH=/opt/aios-orchestrator-gateway/current \
python3 /opt/aios-orchestrator-gateway/current/scripts/recover_pending_decisions.py \
--config /etc/aios-orchestrator-gateway/config.json --plan
```
Application handles at most one leased record and never resends a model prompt
or replays a decision:
```bash
PYTHONPATH=/opt/aios-orchestrator-gateway/current \
python3 /opt/aios-orchestrator-gateway/current/scripts/recover_pending_decisions.py \
--config /etc/aios-orchestrator-gateway/config.json --apply-one
```
## Required live acceptance
Do not mark the live Gate PASS until metadata-only evidence proves, in order:
1. Workspace trigger returned HTTP 202.
2. The remote callback tool received one authenticated request.
3. The bridge returned HTTP 202 from the loopback 18920 decision endpoint.
4. The event reached `DECIDED`, `callback_state=APPLIED`, and
`decision_applied=true`.
5. Recovery planning and one apply cycle did not create a second outbox side
effect for the same event.
Never include Authorization, token values, resolved SecretRefs, prompts, or raw
model output in logs or RESULT files.
@@ -0,0 +1,89 @@
openapi: 3.0.3
info:
title: AIOS Orchestrator Gateway metadata API
version: 0.2.0
servers:
- url: http://127.0.0.1:18920
paths:
/v1/orchestrator/events:
post:
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'202':
description: Event accepted
'409':
description: Idempotency or state conflict
'422':
description: Invalid event
/v1/orchestrator/events/{event_id}:
get:
security:
- bearerAuth: []
parameters:
- in: path
name: event_id
required: true
schema:
type: string
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
responses:
'200':
description: Metadata-only event status
content:
application/json:
schema:
type: object
additionalProperties: false
required: [event_id, status, trigger_http_status, trigger_error_category, trigger_channel, remote_id, callback_state, callback_deadline, callback_attempts, decision_applied, state_version_after]
properties:
event_id:
type: string
status:
enum: [RECEIVED, AWAITING_DECISION, DECIDED, TRIGGER_FAILED, DEAD_LETTER]
trigger_channel:
type: string
nullable: true
enum: [workspace_agent, responses_api]
trigger_http_status:
type: integer
nullable: true
minimum: 100
maximum: 599
trigger_error_category:
type: string
nullable: true
enum: [UNAUTHORIZED, FORBIDDEN, NOT_FOUND, NOT_RUNNABLE, NETWORK_ERROR]
remote_id:
type: string
nullable: true
callback_state:
enum: [PENDING, SENT, AWAITING_CALLBACK, CALLBACK_RECEIVED, APPLIED, STALE_SUPERSEDED, DEAD_LETTER]
callback_deadline:
type: integer
nullable: true
minimum: 0
callback_attempts:
type: integer
minimum: 0
decision_applied:
type: boolean
state_version_after:
type: integer
nullable: true
minimum: 0
'401':
description: Missing or invalid bearer
'404':
description: Event not found
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
VERSION=V2R1C2
EXPECTED_TARGET=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
TAG_OBJECT=a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36
REPO=/data/aios-gitops/repo.git
SOURCE=/opt/aios-v2r1c2
TAKEOVER=/data/aios-gitops/worktrees/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722
CANDIDATE_SERVICE=aios-v2r1c2-portal.service
OLD_SERVICE=aios-orchestrator-gateway.service
NEW_SERVICE=aios-v2r1c2-production.service
CANDIDATE_STATE=/var/lib/aios-v2r1c2-portal
PRODUCTION_STATE=/var/lib/aios-v2r1c2-production
RUN_ID=${AIOS_RELEASE_RUN_ID:-AIOS-V2R1C2-PRODUCTION-20260722T011836Z}
EVIDENCE_DIR=/var/lib/aios-gitops/acceptance/$RUN_ID
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
RELEASE_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd)
UNIT_SOURCE=$RELEASE_ROOT/deploy/aios-v2/systemd/aios-v2r1c2-production.service
ENV_SOURCE=$RELEASE_ROOT/deploy/aios-v2/systemd/aios-v2r1c2-production.env.example
LOCK=/run/lock/aios-v2r1c2-production-release.lock
require_root() {
test "$(id -u)" -eq 0 || { echo 'root required' >&2; exit 77; }
}
health_old() {
curl -fsS --max-time 10 http://127.0.0.1:18920/healthz
}
health_new() {
curl -fsS --max-time 10 http://127.0.0.1:18920/api/v2/health
}
health_candidate() {
curl -fsS --max-time 10 http://127.0.0.1:18921/api/v2/health
}
wait_candidate() {
for _ in $(seq 1 30); do
if health_candidate; then
return 0
fi
sleep 1
done
return 1
}
preflight() {
require_root
test "$(git --git-dir="$REPO" rev-parse refs/tags/$VERSION)" = "$TAG_OBJECT"
test "$(git --git-dir="$REPO" rev-list -n 1 "$VERSION")" = "$EXPECTED_TARGET"
test -f "$SOURCE/active-runtime/app/aios_web/release_application.py"
test -f "$UNIT_SOURCE"
test -f "$ENV_SOURCE"
command -v sqlite3 >/dev/null
systemctl is-active --quiet "$CANDIDATE_SERVICE"
systemctl is-active --quiet "$OLD_SERVICE"
health_candidate >/dev/null
health_old >/dev/null
(
cd "$TAKEOVER/artifacts/v2r1c2"
sha256sum -c checksums.sha256 >/dev/null
)
diff -qr --exclude=__pycache__ "$TAKEOVER/active-runtime/app" "$SOURCE/active-runtime/app" >/dev/null
diff -qr "$TAKEOVER/plans" "$SOURCE/plans" >/dev/null
echo PREFLIGHT=PASS
}
prepare() {
preflight
install -d -m 0750 "$EVIDENCE_DIR" "$EVIDENCE_DIR/backup"
health_old >"$EVIDENCE_DIR/production-health-before.json"
health_candidate >"$EVIDENCE_DIR/candidate-health-before.json"
systemctl cat "$OLD_SERVICE" >"$EVIDENCE_DIR/backup/$OLD_SERVICE.unit"
cp -a /etc/aios-orchestrator-gateway "$EVIDENCE_DIR/backup/"
cp -a /etc/systemd/system/aios-orchestrator-gateway.service "$EVIDENCE_DIR/backup/"
if test -e "$PRODUCTION_STATE"; then
mv "$PRODUCTION_STATE" "$EVIDENCE_DIR/backup/aios-v2r1c2-production-state.preexisting"
fi
systemctl stop "$CANDIDATE_SERVICE"
trap 'systemctl start "$CANDIDATE_SERVICE" || true' EXIT
while IFS= read -r -d '' database; do
sqlite3 "$database" 'PRAGMA wal_checkpoint(TRUNCATE);' >/dev/null
test "$(sqlite3 "$database" 'PRAGMA quick_check;')" = ok
done < <(find "$CANDIDATE_STATE" -maxdepth 1 -type f -name '*.sqlite3' -print0)
install -d -o aios -g aios -m 0750 "$PRODUCTION_STATE"
cp -a "$CANDIDATE_STATE/." "$PRODUCTION_STATE/"
chown -R aios:aios "$PRODUCTION_STATE"
while IFS= read -r -d '' database; do
test "$(sqlite3 "$database" 'PRAGMA quick_check;')" = ok
done < <(find "$PRODUCTION_STATE" -maxdepth 1 -type f -name '*.sqlite3' -print0)
install -o root -g root -m 0644 "$UNIT_SOURCE" /etc/systemd/system/aios-v2r1c2-production.service
install -d -o root -g aios -m 0750 /etc/aios-v2
install -o root -g aios -m 0640 "$ENV_SOURCE" /etc/aios-v2/aios-v2r1c2-production.env
systemctl daemon-reload
systemctl start "$CANDIDATE_SERVICE"
wait_candidate >"$EVIDENCE_DIR/candidate-health-after-state-copy.json"
trap - EXIT
echo PREPARE=PASS
}
rollback() {
require_root
systemctl stop "$NEW_SERVICE" || true
systemctl disable "$NEW_SERVICE" >/dev/null 2>&1 || true
systemctl enable "$OLD_SERVICE" >/dev/null 2>&1 || true
systemctl start "$OLD_SERVICE"
health_old >"$EVIDENCE_DIR/rollback-health.json"
echo ROLLBACK=PASS
}
promote() {
require_root
test -f /etc/systemd/system/aios-v2r1c2-production.service
test -f /etc/aios-v2/aios-v2r1c2-production.env
! systemctl is-active --quiet "$NEW_SERVICE"
systemctl is-active --quiet "$CANDIDATE_SERVICE"
health_candidate >/dev/null
health_old >"$EVIDENCE_DIR/production-health-switch-input.json"
trap 'trap - ERR; rollback' ERR
systemctl stop "$OLD_SERVICE"
systemctl start "$NEW_SERVICE"
for _ in $(seq 1 30); do
if health_new >"$EVIDENCE_DIR/production-health-after-switch.json" 2>/dev/null; then
echo PROMOTE=PASS
trap - ERR
return 0
fi
sleep 1
done
return 1
}
finalize() {
require_root
systemctl is-active --quiet "$NEW_SERVICE"
health_new >"$EVIDENCE_DIR/production-health-final.json"
systemctl disable "$OLD_SERVICE" >/dev/null
systemctl enable "$NEW_SERVICE" >/dev/null
echo FINALIZE=PASS
}
exec 9>"$LOCK"
flock -n 9 || { echo 'release operation already running' >&2; exit 75; }
case "${1:-}" in
preflight) preflight ;;
prepare) prepare ;;
promote) promote ;;
rollback) rollback ;;
finalize) finalize ;;
*) echo "usage: $0 {preflight|prepare|promote|rollback|finalize}" >&2; exit 64 ;;
esac
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
RUN_ID=AIOS-V2R1C2-PRODUCTION-20260722T011836Z
CONTROL=/data/aios-gitops/worktrees/controller/ops/aios-v2/v2r1c2-production-control.sh
REPO=/data/aios-gitops/repo.git
EVIDENCE_DIR=/var/lib/aios-gitops/acceptance/$RUN_ID
DRIVER=/data/aios-gitops/worktrees/controller/tools/run_online_smoke_18920.py
TEMP_SOURCE=
cleanup_temp() {
case "$TEMP_SOURCE" in
/tmp/aios-v2r1c2-production-acceptance.*)
test -d "$TEMP_SOURCE" && rm -rf -- "$TEMP_SOURCE"
;;
'') ;;
*) echo "refusing to remove unexpected temporary path: $TEMP_SOURCE" >&2; return 1 ;;
esac
}
rollback_on_error() {
code=$?
trap - ERR
AIOS_RELEASE_RUN_ID="$RUN_ID" "$CONTROL" rollback || true
cleanup_temp || true
exit "$code"
}
trap rollback_on_error ERR
AIOS_RELEASE_RUN_ID="$RUN_ID" "$CONTROL" promote
systemctl is-active --quiet aios-v2r1c2-production.service
! systemctl is-active --quiet aios-orchestrator-gateway.service
curl -fsS --max-time 10 http://127.0.0.1:18920/api/v2/health >"$EVIDENCE_DIR/health-after-promote.json"
curl -fsS --max-time 10 http://127.0.0.1:18921/api/v2/health >"$EVIDENCE_DIR/candidate-health-during-production.json"
TEMP_SOURCE=$(mktemp -d /tmp/aios-v2r1c2-production-acceptance.XXXXXX)
git --git-dir="$REPO" archive 8cdca463aff9707edee00e20a3b56884130f6978 | tar -x -C "$TEMP_SOURCE"
(
cd "$TEMP_SOURCE"
python3 tools/aios_v2r1c2_acceptance.py \
--source-oid 3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24 \
--online \
--base-url http://127.0.0.1:18920 \
--evidence-output "$EVIDENCE_DIR/production-acceptance.json"
)
python3 "$DRIVER" "$TEMP_SOURCE" "$EVIDENCE_DIR/production-online-smoke.json"
python3 - "$EVIDENCE_DIR/production-acceptance.json" "$EVIDENCE_DIR/production-online-smoke.json" <<'PY'
import json
import sys
for path in sys.argv[1:]:
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
if data.get('passed') is not True:
raise SystemExit(f'acceptance failed: {path}')
PY
systemctl show aios-v2r1c2-production.service \
-p ActiveState -p SubState -p MainPID -p NRestarts \
>"$EVIDENCE_DIR/production-service-status.txt"
sha256sum "$EVIDENCE_DIR"/production-acceptance.json \
"$EVIDENCE_DIR"/production-online-smoke.json \
"$EVIDENCE_DIR"/health-after-promote.json \
>"$EVIDENCE_DIR/production-evidence.sha256"
cleanup_temp
TEMP_SOURCE=
trap - ERR
echo SWITCH_VALIDATED=PASS
@@ -1,15 +0,0 @@
{
"patch_name": "gitea-tag-publication-atomicity",
"branch": "patch/gitea-tag-publication-atomicity-20260723",
"base": "main",
"risk": "high",
"description": "Fail-closed, resumable Gitea release-tag publication with exact remote ref, peeled target, API and listing verification before baseline update.",
"owner_approval": "received",
"constraints": {
"force_push": false,
"tag_rewrite": false,
"direct_main_push": false,
"secret_commit": false,
"production_runtime_anchor_change": false
}
}
@@ -1,9 +0,0 @@
{
"id": "patch-1784043007770-e97206c3",
"patch_name": "gitops-isolated-worktree-governance-20260714T232948UTC8",
"branch": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"risk": "high",
"description": "Root-cause remediation for recurring ACL and shared-worktree failures: unified aiman:gitops runtime identity, UMask=0002, inherited default ACLs, per-task isolated Git worktrees, startup preflight, migration/rollback scripts, systemd drop-in templates, audit evidence, and an OpenClaw execution task. No production business deployment in this patch.",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T15:30:07Z"
}
-10
View File
@@ -1,10 +0,0 @@
{
"id": "nl-1784681185786-19ac0f82",
"text": "Create a safe GitOps plan for tag V2R1C2. The tag target must be commit 3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24. Evidence closure commit is 8cdca463aff9707edee00e20a3b56884130f6978. Do not tag the current workspace HEAD. Do not modify main. Do not force push. Do not promote production. If the target commit cannot be bound explicitly, keep the operation blocked.",
"intent": "unknown",
"action_plan": [],
"execute": false,
"owner_approval_required": true,
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-22T00:46:25Z"
}
@@ -1,9 +0,0 @@
{
"type": "branch",
"branch": "patch/gitea-tag-publication-atomicity-20260723",
"base": "main",
"created_by": "owner-gitea-tag-atomicity-20260723",
"created_at": "2026-07-23T04:59:13Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -1,9 +0,0 @@
{
"type": "branch",
"branch": "task/codex/gitops-isolated-worktree-migration-20260715T001652UTC8",
"base": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T16:17:28Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -1,43 +0,0 @@
{
"base": "main",
"base_oid": "b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870",
"commit_count": 10,
"commits": [
"fc0b9f29bf6dadfbcc8599da6f0ab11a6e19b529",
"3812e1ab0123c7da4726e51179a10746b5bbde83",
"3196d91b12d0ac5d7ea550b0474703a08736471b",
"54093d15437ce0af31acf6feeeffacf6626a59cd",
"27a0ae126b96d63c40544174e7a3859e30eba056",
"83ab67f7e11eae9da4546cab53169f5a6a47e0ae",
"f4b6ac8609faf947d2659b2c32564a26fcb3eb10",
"a3edc628a91a4af3d702aacefe0389f844615bc1",
"e40c05779c435c3726618de81e02e90953d727f3",
"edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
],
"file_count": 15,
"files": [
"active-runtime/app/aios_chatgpt_actions_api.py",
"active-runtime/app/aios_gitops_write_guard.py",
"active-runtime/app/gitea_tag_publication.py",
"docs/gitops/gitea-tag-publication-atomicity.md",
"docs/gitops/openapi-chatgpt-actions.yaml",
"patches/gitea-tag-publication-atomicity-20260723.json",
"patches/patch_gitea-tag-publication-atomicity-20260723.branch.json",
"scripts/generate_delivery_manifest.py",
"scripts/release_version.sh",
"scripts/verify_gitea_credential_rotation.py",
"security/incidents/GITEA-TOKEN-33-EXPOSURE-20260723.redacted.json",
"tests/run_live_gitea_fixture.sh",
"tests/test_chatgpt_actions_tag.py",
"tests/test_gitea_tag_publication.py",
"tests/test_gitea_tag_publication_live.py"
],
"generated_from": {
"commit_range": "b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f",
"commits_command": "git log --reverse --format=%H b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f",
"files_command": "git diff --name-only b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
},
"manifest_path": "reports/gitea-tag-publication-atomicity-delivery.json",
"source_branch": "patch/gitea-tag-publication-atomicity-20260723",
"source_commit": "edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
}
@@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aios.local/schemas/automation-quorum-controller.schema.json",
"title": "AIOS GPT plus one-executor quorum controller configuration",
"type": "object",
"additionalProperties": false,
"required": [
"project_key",
"contract_commit",
"database_path",
"file_inbox_path",
"health_receipts",
"blocker_retry_threshold",
"max_health_age_seconds",
"lease_ttl_seconds",
"gateway_event_url",
"gateway_token_file"
],
"properties": {
"$schema": {"type": "string"},
"project_key": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"database_path": {"type": "string", "pattern": "^/"},
"file_inbox_path": {"type": "string", "pattern": "^/"},
"default_workload": {
"enum": [
"large_repetitive_infrastructure_monitoring",
"complex_code_security_architecture"
]
},
"health_receipts": {
"type": "object",
"additionalProperties": false,
"required": ["central_gpt", "openclaw", "codex"],
"properties": {
"central_gpt": {"type": "string", "pattern": "^/"},
"openclaw": {"type": "string", "pattern": "^/"},
"codex": {"type": "string", "pattern": "^/"}
}
},
"git_ref_polling": {
"type": "object",
"additionalProperties": false,
"required": ["repository", "refs"],
"properties": {
"repository": {"type": "string", "pattern": "^/"},
"refs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^refs/"}
}
}
},
"blocker_retry_threshold": {"type": "integer", "minimum": 1, "maximum": 20},
"max_health_age_seconds": {"type": "integer", "minimum": 5, "maximum": 300},
"lease_ttl_seconds": {"type": "integer", "minimum": 15, "maximum": 3600},
"gateway_event_url": {"type": "string", "const": "http://127.0.0.1:18920/v1/orchestrator/events"},
"gateway_token_file": {"type": "string", "pattern": "^/"}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AIOS Callback Transport",
"type": "object",
"additionalProperties": false,
"required": ["mode", "endpoint", "approved_mcp_tunnel_endpoints"],
"properties": {
"mode": {
"enum": ["remote_https_action", "secure_mcp_tunnel"]
},
"endpoint": {
"type": "string",
"format": "uri",
"pattern": "^https://"
},
"approved_mcp_tunnel_endpoints": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "format": "uri", "pattern": "^https://"}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aios.local/schemas/codex-command.schema.json",
"title": "AIOS Codex command",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "command_id", "project_key", "work_unit_id", "contract_commit", "state_version", "execution"],
"properties": {
"schema_version": {"const": 1},
"command_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"project_key": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"work_unit_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"state_version": {"type": "integer", "minimum": 0},
"execution": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "input"],
"properties": {
"kind": {"enum": ["acceptance_probe", "codex"]},
"input": {"type": "string", "maxLength": 65536}
}
}
}
}
+145
View File
@@ -0,0 +1,145 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AIOS Orchestrator Decision",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"project_key",
"contract_commit",
"event_id",
"expected_state_version",
"verdict",
"state_updates",
"next_work_units",
"requires_human_input",
"stop_reason"
],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"project_key": {
"type": "string"
},
"contract_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"event_id": {
"type": "string"
},
"expected_state_version": {
"type": "integer",
"minimum": 0
},
"verdict": {
"type": "string",
"enum": [
"BLOCKED",
"COMPLETE",
"DISPATCH",
"NOOP"
]
},
"state_updates": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"next_work_units": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"track_id",
"gate_id",
"primary_agent",
"fallback_agent",
"clean_base_commit",
"allowed_paths",
"required_checks",
"forbidden_operations",
"instruction"
],
"properties": {
"id": {
"type": "string"
},
"track_id": {
"type": "string"
},
"gate_id": {
"type": "string"
},
"primary_agent": {
"type": "string",
"enum": [
"openclaw",
"codex"
]
},
"fallback_agent": {
"type": "string",
"enum": [
"openclaw",
"codex"
]
},
"clean_base_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"allowed_paths": {
"type": "array",
"items": {
"type": "string"
}
},
"required_checks": {
"type": "array",
"items": {
"type": "string"
}
},
"forbidden_operations": {
"type": "array",
"items": {
"type": "string"
}
},
"instruction": {
"type": "string"
}
}
}
},
"requires_human_input": {
"type": "boolean"
},
"stop_reason": {
"type": [
"string",
"null"
]
}
}
}
+102
View File
@@ -0,0 +1,102 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AIOS Orchestrator Event",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"project_key",
"contract_commit",
"event_id",
"source_agent",
"event_type",
"work_unit_id",
"status",
"state_version",
"implementation_commit",
"result_commit",
"blocker",
"evidence_refs",
"payload"
],
"properties": {
"schema_version": {
"const": 1
},
"project_key": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"contract_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"event_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"source_agent": {
"enum": [
"openclaw",
"codex",
"gitea",
"system",
"workspace-agent"
]
},
"event_type": {
"type": "string"
},
"work_unit_id": {
"type": "string"
},
"status": {
"enum": [
"READY",
"RUNNING",
"PASS",
"BLOCKED",
"FAIL",
"TIMEOUT",
"NOT_EXECUTED"
]
},
"state_version": {
"type": "integer",
"minimum": 0
},
"implementation_commit": {
"type": [
"string",
"null"
],
"pattern": "^[0-9a-f]{40}$"
},
"result_commit": {
"type": [
"string",
"null"
],
"pattern": "^[0-9a-f]{40}$"
},
"blocker": {
"type": [
"string",
"null"
],
"maxLength": 512
},
"evidence_refs": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 64
},
"payload": {
"type": "object"
}
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Seed exactly one orchestration event when the project has no durable state."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from services.aios_orchestrator_gateway.config import Config
from services.aios_orchestrator_gateway.models import Event
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared
from services.aios_orchestrator_gateway.store import Store
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, required=True)
args = parser.parse_args()
config = Config.from_file(args.config)
store = Store(config.database_path)
if store.project_state(config.project_key) is not None:
print("ORCHESTRATION_BOOTSTRAP=NOOP reason=STATE_EXISTS")
return 0
event = Event.from_dict(
{
"schema_version": 1,
"project_key": config.project_key,
"contract_commit": config.contract_commit,
"event_id": "bootstrap-" + config.contract_commit[:24],
"source_agent": "system",
"event_type": "orchestration_bootstrap",
"work_unit_id": "ORCHESTRATION-BOOTSTRAP",
"status": "READY",
"state_version": 0,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {
"reason": "initial_gateway_install",
"polling_enabled": True,
"openclaw_sync_required": True,
},
}
)
path = config.event_inbox_dir / "pending" / f"{event.event_id}.json"
atomic_write_bytes_shared(
path,
(event.canonical_json() + "\n").encode(),
mode=0o640,
group_writable_directory=True,
)
print(f"ORCHESTRATION_BOOTSTRAP=PASS event_id={event.event_id}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Generate the delivery file list from Git instead of hand-maintained counts."""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
def git(root: Path, *args: str) -> list[str]:
output = subprocess.check_output(
["git", *args], cwd=root, text=True, encoding="utf-8"
)
return [line for line in output.splitlines() if line]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="main")
parser.add_argument(
"--output",
default="reports/gitea-tag-publication-atomicity-delivery.json",
)
args = parser.parse_args()
root = Path(
subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True, encoding="utf-8"
).strip()
)
output = Path(args.output)
if output.is_absolute() or ".." in output.parts:
raise SystemExit("output must remain inside the repository")
output_path = root / output
output_path.parent.mkdir(parents=True, exist_ok=True)
base_oid = git(root, "rev-parse", "--verify", f"{args.base}^{{commit}}")[0]
source_commit = git(root, "rev-parse", "--verify", "HEAD^{commit}")[0]
source_branch = git(root, "symbolic-ref", "--short", "HEAD")[0]
commit_range = f"{base_oid}..{source_commit}"
commits = git(root, "log", "--reverse", "--format=%H", commit_range)
files = [
path
for path in git(root, "diff", "--name-only", commit_range)
if path != output.as_posix()
]
payload = {
"base": args.base,
"base_oid": base_oid,
"source_branch": source_branch,
"source_commit": source_commit,
"commit_count": len(commits),
"commits": commits,
"file_count": len(files),
"files": files,
"manifest_path": output.as_posix(),
"generated_from": {
"commit_range": commit_range,
"files_command": f"git diff --name-only {commit_range}",
"commits_command": f"git log --reverse --format=%H {commit_range}",
},
}
output_path.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import tempfile
from services.aios_orchestrator_gateway.callback_transport import CallbackTransport
def atomic_write(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--transport-config", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
value = json.loads(args.transport_config.read_text(encoding="utf-8"))
if "callback_transport" in value:
value = value["callback_transport"]
transport = CallbackTransport.from_dict(value)
manifest = (
transport.action_openapi()
if transport.mode == "remote_https_action"
else {"endpoint": transport.endpoint, "tools": [transport.mcp_tool()]}
)
atomic_write(args.output, manifest)
print(f"TOOL_MANIFEST=PASS mode={transport.mode}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
SOURCE="${AIOS_SOURCE_WORKTREE:-$ROOT/workspace}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: bootstrap must run as root" >&2
exit 2
fi
getent passwd "$RUNTIME_USER" >/dev/null
getent group "$RUNTIME_GROUP" >/dev/null
[[ -d "$SOURCE/.git" || -f "$SOURCE/.git" ]] || {
echo "ERROR: source worktree not found: $SOURCE" >&2
exit 3
}
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 "$ROOT" "$WORKTREES"
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 \
"$WORKTREES/openclaw" "$WORKTREES/chatgpt" "$WORKTREES/controller"
if [[ ! -d "$BARE" ]]; then
sudo -u "$RUNTIME_USER" -g "$RUNTIME_GROUP" git clone --bare "$SOURCE" "$BARE"
fi
chown -R "$RUNTIME_USER:$RUNTIME_GROUP" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec chmod 2775 {} +
find "$BARE" "$WORKTREES" -type f -exec chmod ug+rw {} +
setfacl -R -m "u:${RUNTIME_USER}:rwX,g:${RUNTIME_GROUP}:rwX,m::rwX" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
cat <<EOF
bootstrap_status: PASS
root: $ROOT
source_worktree: $SOURCE
bare_repo: $BARE
worktree_root: $WORKTREES
runtime_user: $RUNTIME_USER
runtime_group: $RUNTIME_GROUP
EOF
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
EXPECTED_USER="${AIOS_RUNTIME_USER:-aiman}"
EXPECTED_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
EXPECTED_UMASK="${AIOS_RUNTIME_UMASK:-0002}"
TASK_DIR="${AIOS_TASK_DIR:-.agent/tasks}"
RESULT_DIR="${AIOS_RESULT_DIR:-.agent/results}"
fail() {
echo "preflight_status: FAIL"
echo "reason: $1"
exit 1
}
USER_NOW="$(id -un)"
GROUPS_NOW="$(id -Gn)"
UMASK_NOW="$(umask)"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
BRANCH="$(git branch --show-current 2>/dev/null || true)"
[[ "$USER_NOW" == "$EXPECTED_USER" ]] || fail "runtime user is $USER_NOW, expected $EXPECTED_USER"
[[ " $GROUPS_NOW " == *" $EXPECTED_GROUP "* ]] || fail "runtime user is not in $EXPECTED_GROUP"
[[ "$UMASK_NOW" == "$EXPECTED_UMASK" || "$UMASK_NOW" == "002" ]] || fail "umask is $UMASK_NOW, expected $EXPECTED_UMASK"
[[ -n "$ROOT" ]] || fail "not inside a git worktree"
[[ "$ROOT" == /data/aios-gitops/worktrees/* ]] || fail "shared source worktree is prohibited: $ROOT"
[[ -w "$ROOT" ]] || fail "worktree root is not writable"
[[ -w "$GIT_DIR" ]] || fail "git dir is not writable: $GIT_DIR"
[[ ! -e "$GIT_DIR/index.lock" ]] || fail "git index lock exists"
install -d -m 2775 "$TASK_DIR" "$RESULT_DIR"
[[ -w "$TASK_DIR" ]] || fail "task directory is not writable: $TASK_DIR"
[[ -w "$RESULT_DIR" ]] || fail "result directory is not writable: $RESULT_DIR"
[[ -z "$(git status --porcelain)" ]] || fail "worktree is not clean at task start"
cat <<EOF
preflight_status: PASS
runtime_user: $USER_NOW
runtime_groups: $GROUPS_NOW
umask: $UMASK_NOW
worktree: $ROOT
git_dir: $GIT_DIR
branch: $BRANCH
workspace_clean: true
task_path_writable: true
result_path_writable: true
git_lock_present: false
EOF
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-}"
AGENT="${2:-}"
TASK_ID="${3:-}"
BASE_REF="${4:-origin/main}"
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
usage() {
echo "usage: $0 create|remove <openclaw|chatgpt|controller> <task-id> [base-ref]" >&2
exit 64
}
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "ERROR: invalid task id" >&2
exit 65
}
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
echo "ERROR: must run as $RUNTIME_USER" >&2
exit 66
}
[[ "$(id -gn)" == "$RUNTIME_GROUP" || " $(id -Gn) " == *" $RUNTIME_GROUP "* ]] || {
echo "ERROR: user must be in $RUNTIME_GROUP" >&2
exit 67
}
[[ "$(umask)" == "0002" || "$(umask)" == "002" ]] || {
echo "ERROR: umask must be 0002" >&2
exit 68
}
[[ -d "$BARE" ]] || {
echo "ERROR: bare repository missing: $BARE" >&2
exit 69
}
PATHNAME="$WORKTREES/$AGENT/$TASK_ID"
BRANCH="task/$AGENT/$TASK_ID"
case "$ACTION" in
create)
[[ ! -e "$PATHNAME" ]] || {
echo "ERROR: worktree already exists: $PATHNAME" >&2
exit 70
}
git --git-dir="$BARE" fetch --all --prune
git --git-dir="$BARE" worktree add -b "$BRANCH" "$PATHNAME" "$BASE_REF"
install -d -m 2775 "$PATHNAME/.agent/tasks/$AGENT" "$PATHNAME/.agent/results/$AGENT"
setfacl -m "u:${RUNTIME_USER}:rwx,g:${RUNTIME_GROUP}:rwx,m::rwx" "$PATHNAME"
find "$PATHNAME" -type d -exec chmod g+s {} +
find "$PATHNAME" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
printf 'worktree: %s\nbranch: %s\nbase: %s\n' "$PATHNAME" "$BRANCH" "$BASE_REF"
;;
remove)
[[ -d "$PATHNAME" ]] || {
echo "ERROR: worktree not found: $PATHNAME" >&2
exit 71
}
[[ -z "$(git -C "$PATHNAME" status --porcelain)" ]] || {
echo "ERROR: worktree is not clean; retain for investigation" >&2
exit 72
}
git --git-dir="$BARE" worktree remove "$PATHNAME"
printf 'removed: %s\n' "$PATHNAME"
;;
esac
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
[[ ${EUID} -eq 0 ]] || { echo "root required" >&2; exit 1; }
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
CONFIG=${1:-/etc/aios-codex-watcher/config.json}
[[ "$CONFIG" = /* ]] || { echo "config path must be absolute" >&2; exit 1; }
[[ -f "$CONFIG" && ! -L "$CONFIG" ]] || { echo "pre-provisioned regular config required" >&2; exit 1; }
for path in \
services/aios_codex_watcher/__init__.py \
services/aios_codex_watcher/__main__.py \
services/aios_codex_watcher/watcher.py \
systemd/aios-codex-watcher.service \
systemd/aios-codex-watcher.timer \
systemd/aios-codex-watcher.sysusers \
systemd/aios-codex-watcher.tmpfiles; do
[[ -f "$ROOT/$path" && ! -L "$ROOT/$path" ]] || { echo "missing or unsafe: $path" >&2; exit 1; }
done
digest=$(sha256sum \
"$ROOT/services/aios_codex_watcher/__init__.py" \
"$ROOT/services/aios_codex_watcher/__main__.py" \
"$ROOT/services/aios_codex_watcher/watcher.py" | sha256sum | awk '{print $1}')
release="/opt/aios-codex-watcher/releases/$digest"
install -d -o root -g root -m 0755 "$release/services/aios_codex_watcher" "$release/bin"
install -o root -g root -m 0644 "$ROOT/services/aios_codex_watcher/"*.py "$release/services/aios_codex_watcher/"
launcher="$release/bin/aios-codex-watcher"
printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \
"export PYTHONPATH='$release'" \
"exec /usr/bin/python3 -m services.aios_codex_watcher \"\$@\"" >"$launcher"
chown root:root "$launcher"
chmod 0755 "$launcher"
install -d -o root -g root -m 0755 /opt/aios-codex-watcher /etc/aios-codex-watcher
ln -sfn "releases/$digest" /opt/aios-codex-watcher/current.new
mv -Tf /opt/aios-codex-watcher/current.new /opt/aios-codex-watcher/current
install -o root -g root -m 0644 "$ROOT/systemd/aios-codex-watcher.service" /etc/systemd/system/
install -o root -g root -m 0644 "$ROOT/systemd/aios-codex-watcher.timer" /etc/systemd/system/
install -o root -g root -m 0644 "$ROOT/systemd/aios-codex-watcher.sysusers" /usr/lib/sysusers.d/
install -o root -g root -m 0644 "$ROOT/systemd/aios-codex-watcher.tmpfiles" /usr/lib/tmpfiles.d/
systemd-sysusers /usr/lib/sysusers.d/aios-codex-watcher.sysusers
systemd-tmpfiles --create /usr/lib/tmpfiles.d/aios-codex-watcher.tmpfiles
commands=/data/aios-gitops/workspace/agents/commands
for directory in "$commands/active" "$commands/completed"; do
[[ -d "$directory" && ! -L "$directory" ]] || { echo "missing or unsafe command directory: $directory" >&2; exit 1; }
[[ $(stat -c '%U:%G' "$directory") == aios-orchestrator:aios-orchestrator ]] || { echo "unexpected command directory owner: $directory" >&2; exit 1; }
mode=$(stat -c '%a' "$directory")
(( (8#$mode & 2) == 0 )) || { echo "world-writable command directory: $directory" >&2; exit 1; }
done
if [[ -e "$commands/dead-letter" || -L "$commands/dead-letter" ]]; then
[[ -d "$commands/dead-letter" && ! -L "$commands/dead-letter" ]] || { echo "unsafe dead-letter directory" >&2; exit 1; }
else
install -d -o aios-orchestrator -g aios-orchestrator -m 0750 "$commands/dead-letter"
fi
[[ $(stat -c '%U:%G' "$commands/dead-letter") == aios-orchestrator:aios-orchestrator ]] || { echo "unexpected dead-letter owner" >&2; exit 1; }
mode=$(stat -c '%a' "$commands/dead-letter")
(( (8#$mode & 2) == 0 )) || { echo "world-writable dead-letter directory" >&2; exit 1; }
printf '{"release_sha256":"%s","installed_at":"%s"}\n' "$digest" "$(date -u +%FT%TZ)" >"$release/receipt.json"
chmod 0644 "$release/receipt.json"
systemctl daemon-reload
systemctl enable --now aios-codex-watcher.timer
printf 'installed_sha256=%s\n' "$digest"
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
SOURCE_ROOT="${SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
CONFIG_ROOT="${CONFIG_ROOT:-/etc/aios-orchestrator-gateway}"
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
UNIT_ROOT="${UNIT_ROOT:-/etc/systemd/system}"
SYSUSERS_ROOT="${SYSUSERS_ROOT:-/etc/sysusers.d}"
TMPFILES_ROOT="${TMPFILES_ROOT:-/etc/tmpfiles.d}"
DRY_RUN=0
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=1
fi
fail() {
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=%s\n' "$1" >&2
exit 2
}
[[ "$(id -u)" -eq 0 ]] || fail OWNER_INSTALL_REQUIRED
for cmd in git python3 systemd-analyze systemctl systemd-sysusers systemd-tmpfiles sha256sum find sort xargs; do
command -v "$cmd" >/dev/null 2>&1 || fail "MISSING_COMMAND_${cmd}"
done
if [[ -f "$SOURCE_ROOT/SHA256SUMS" ]]; then
(cd "$SOURCE_ROOT" && sha256sum --check SHA256SUMS)
fi
for path in \
"$SOURCE_ROOT/services/aios_orchestrator_gateway" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
"$SOURCE_ROOT/systemd/aios-orchestrator-remote-tool.service" \
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json"; do
[[ -e "$path" ]] || fail "MISSING_SOURCE_${path}"
done
PYCACHE="/tmp/aios-orchestrator-install-pycache-$$"
rm -rf "$PYCACHE"
PYTHONPYCACHEPREFIX="$PYCACHE" python3 -m compileall -q "$SOURCE_ROOT/services" "$SOURCE_ROOT/scripts"
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$SOURCE_ROOT" \
python3 -m unittest -v tests.test_aios_orchestrator_gateway
rm -rf "$PYCACHE"
for script in "$SOURCE_ROOT"/scripts/*.sh; do bash -n "$script"; done
python3 -m json.tool "$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" >/dev/null
systemd-analyze verify \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer"
SOURCE_FILES=(
README-AIOS-ORCHESTRATOR-GATEWAY.md
docs/runbooks/A0-CALLBACK-TRANSPORT-AND-PENDING-RECOVERY.md
config/aios-orchestrator-gateway.example.json
schemas/decision.schema.json
schemas/event.schema.json
scripts/bootstrap_orchestration_loop.py
scripts/install_aios_orchestrator_gateway.sh
scripts/report_orchestrator_event.py
scripts/rollback_aios_orchestrator_gateway.sh
scripts/verify_aios_orchestrator_gateway.sh
systemd/aios-orchestrator-gateway-poll.service
systemd/aios-orchestrator-gateway-poll.timer
systemd/aios-orchestrator-gateway.service
systemd/aios-orchestrator-gateway.sysusers
systemd/aios-orchestrator-gateway.tmpfiles
systemd/aios-orchestrator-remote-tool.service
schemas/callback-transport.schema.json
scripts/generate_orchestrator_tool_manifest.py
scripts/recover_pending_decisions.py
tests/test_aios_orchestrator_gateway.py
tests/test_aios_orchestrator_gateway_rollback.sh
tests/test_aios_callback_recovery.py
workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md
workspace-agent/orchestrator-decision-action.openapi.yaml
openapi/aios-orchestrator-gateway.openapi.yaml
)
while IFS= read -r path; do SOURCE_FILES+=("$path"); done < <(
cd "$SOURCE_ROOT"
find services/aios_orchestrator_gateway -type f ! -path '*/__pycache__/*' ! -name '*.pyc' -print | sort
)
for path in "${SOURCE_FILES[@]}"; do
[[ -f "$SOURCE_ROOT/$path" && ! -L "$SOURCE_ROOT/$path" ]] || fail "INVALID_SOURCE_FILE_${path}"
done
SOURCE_DIGEST="$(
cd "$SOURCE_ROOT"
printf '%s\0' "${SOURCE_FILES[@]}" | sort -z | xargs -0 sha256sum \
| sha256sum \
| awk '{print $1}'
)"
[[ "$SOURCE_DIGEST" =~ ^[0-9a-f]{64}$ ]] || fail SOURCE_DIGEST_INVALID
RELEASE_DIR="$INSTALL_ROOT/releases/$SOURCE_DIGEST"
if [[ "$DRY_RUN" -eq 1 ]]; then
printf 'AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS digest=%s release=%s\n' \
"$SOURCE_DIGEST" "$RELEASE_DIR"
exit 0
fi
systemd-sysusers "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers"
install -d -m 0755 "$INSTALL_ROOT/releases"
install -d -o root -g aios-orchestrator -m 0750 "$CONFIG_ROOT"
install -d -m 0700 "$STATE_ROOT"
systemd-tmpfiles --create "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles"
if [[ ! -d "$RELEASE_DIR" ]]; then
TMP_RELEASE="$INSTALL_ROOT/releases/.tmp-$SOURCE_DIGEST-$$"
install -d -m 0755 "$TMP_RELEASE"
for path in "${SOURCE_FILES[@]}"; do
install -d -m 0755 "$TMP_RELEASE/$(dirname "$path")"
cp -a "$SOURCE_ROOT/$path" "$TMP_RELEASE/$path"
done
printf '%s\n' "$SOURCE_DIGEST" > "$TMP_RELEASE/SOURCE_DIGEST"
chmod -R go-w "$TMP_RELEASE"
mv "$TMP_RELEASE" "$RELEASE_DIR"
fi
PREVIOUS_TARGET=""
if [[ -L "$INSTALL_ROOT/current" ]]; then
PREVIOUS_TARGET="$(readlink -f "$INSTALL_ROOT/current")"
fi
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$UNIT_ROOT/aios-orchestrator-gateway.service"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$UNIT_ROOT/aios-orchestrator-gateway-poll.service"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
"$UNIT_ROOT/aios-orchestrator-gateway-poll.timer"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
"$SYSUSERS_ROOT/aios-orchestrator-gateway.conf"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
"$TMPFILES_ROOT/aios-orchestrator-gateway.conf"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-remote-tool.service" \
"$UNIT_ROOT/aios-orchestrator-remote-tool.service"
if [[ ! -f "$CONFIG_ROOT/inbound.token" ]]; then
python3 - <<'PY' > "$CONFIG_ROOT/inbound.token"
import secrets, sys
sys.stdout.write(secrets.token_urlsafe(48))
PY
fi
[[ ! -L "$CONFIG_ROOT/inbound.token" ]] || fail INBOUND_TOKEN_SYMLINK
chown root:aios-orchestrator "$CONFIG_ROOT/inbound.token"
chmod 0640 "$CONFIG_ROOT/inbound.token"
if [[ ! -f "$CONFIG_ROOT/callback.token" ]]; then
python3 - <<'PY' > "$CONFIG_ROOT/callback.token"
import secrets, sys
sys.stdout.write(secrets.token_urlsafe(48))
PY
fi
[[ ! -L "$CONFIG_ROOT/callback.token" ]] || fail CALLBACK_TOKEN_SYMLINK
chown root:aios-orchestrator "$CONFIG_ROOT/callback.token"
chmod 0640 "$CONFIG_ROOT/callback.token"
# Historical installers appended one LF. Remove only that exact legacy byte in
# place; reject every other newline form without ever copying or printing data.
python3 - "$CONFIG_ROOT/config.json" "$CONFIG_ROOT/inbound.token" "$CONFIG_ROOT/callback.token" <<'PY'
import json, os, pathlib, stat, sys
config_path = pathlib.Path(sys.argv[1])
names = list(sys.argv[2:])
if config_path.is_file() and not config_path.is_symlink():
config = json.loads(config_path.read_text())
candidates = [
config.get("inbound_token_file"),
config.get("callback_token_file"),
(config.get("workspace_agent") or {}).get("access_token_file"),
(config.get("responses_fallback") or {}).get("api_key_file"),
(config.get("openclaw_sync") or {}).get("bearer_token_file"),
]
names.extend(name for name in candidates if isinstance(name, str) and name)
for name in dict.fromkeys(names):
path = pathlib.Path(name)
if not path.is_absolute() or path.is_symlink() or path.resolve() != path:
raise SystemExit("invalid token path")
fd = os.open(name, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0 or info.st_size > 16384:
raise SystemExit("invalid token metadata")
data = os.read(fd, info.st_size)
if data.endswith(b"\n") and b"\n" not in data[:-1] and b"\r" not in data:
os.ftruncate(fd, info.st_size - 1)
os.fsync(fd)
elif b"\n" in data or b"\r" in data:
raise SystemExit("invalid token format")
finally:
os.close(fd)
PY
if [[ ! -f "$CONFIG_ROOT/config.json" ]]; then
install -m 0600 \
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" \
"$CONFIG_ROOT/config.json.example"
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=CONFIGURATION_REQUIRED digest=%s\n' \
"$SOURCE_DIGEST"
exit 2
fi
chown root:aios-orchestrator "$CONFIG_ROOT/config.json"
chmod 0640 "$CONFIG_ROOT/config.json"
python3 -m json.tool "$CONFIG_ROOT/config.json" >/dev/null
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
PYTHONPATH="$RELEASE_DIR" \
python3 - "$CONFIG_ROOT/config.json" <<'PY'
import sys
from pathlib import Path
from services.aios_orchestrator_gateway.config import Config
cfg = Config.from_file(Path(sys.argv[1]))
cfg.inbound_token()
cfg.callback_token()
if cfg.workspace_trigger_id:
cfg.workspace_token()
if cfg.responses_api_key_file:
cfg.responses_api_key()
if cfg.openclaw_sync_endpoint:
cfg.openclaw_sync_token()
print("CONFIG_VALIDATE=PASS")
PY
# Commit the release pointer only after every candidate/config/secret check has
# succeeded. A failed validation therefore leaves the running release intact.
ln -s "$RELEASE_DIR" "$INSTALL_ROOT/.current-new-$$"
mv -Tf "$INSTALL_ROOT/.current-new-$$" "$INSTALL_ROOT/current"
python3 - "$STATE_ROOT/install-receipt.json" "$SOURCE_DIGEST" "$RELEASE_DIR" "$PREVIOUS_TARGET" <<'PY'
import json, os, sys, tempfile
from pathlib import Path
path = Path(sys.argv[1])
data = {
"schema_version": 1,
"source_digest": sys.argv[2],
"current_release": sys.argv[3],
"previous_release": sys.argv[4] or None,
"last_rollback": None,
}
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(prefix=".install-receipt-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temp, 0o600)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temp)
except FileNotFoundError:
pass
PY
chown root:root "$STATE_ROOT/install-receipt.json"
chmod 0600 "$STATE_ROOT/install-receipt.json"
systemctl daemon-reload
systemctl enable aios-orchestrator-gateway.service
systemctl enable aios-orchestrator-gateway-poll.timer
systemctl stop aios-orchestrator-gateway-poll.timer || true
systemctl stop aios-orchestrator-gateway-poll.service || true
rollback_activation() {
if [[ -n "$PREVIOUS_TARGET" && -d "$PREVIOUS_TARGET" ]]; then
bash "$RELEASE_DIR/scripts/rollback_aios_orchestrator_gateway.sh" || true
else
systemctl stop aios-orchestrator-gateway.service || true
fi
fail ACTIVATION_FAILED_ROLLED_BACK
}
systemctl restart aios-orchestrator-gateway.service || rollback_activation
python3 - <<'PY' || rollback_activation
import json, time, urllib.request
for _ in range(30):
try:
with urllib.request.urlopen("http://127.0.0.1:18920/healthz", timeout=1) as response:
body = json.loads(response.read(65536))
if response.status == 200 and body.get("status") == "active":
raise SystemExit(0)
except Exception:
pass
time.sleep(0.5)
raise SystemExit(1)
PY
systemctl start aios-orchestrator-gateway-poll.timer || rollback_activation
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
PYTHONPATH="$INSTALL_ROOT/current" \
python3 "$INSTALL_ROOT/current/scripts/bootstrap_orchestration_loop.py" \
--config "$CONFIG_ROOT/config.json"
systemctl start aios-orchestrator-gateway-poll.service || rollback_activation
if [[ -f "$CONFIG_ROOT/remote-tool.json" ]]; then
[[ ! -L "$CONFIG_ROOT/remote-tool.json" ]] || fail REMOTE_TOOL_CONFIG_SYMLINK
chown root:aios-orchestrator "$CONFIG_ROOT/remote-tool.json"
chmod 0640 "$CONFIG_ROOT/remote-tool.json"
PYTHONPATH="$RELEASE_DIR" python3 - "$CONFIG_ROOT/remote-tool.json" <<'PY'
import sys
from pathlib import Path
from services.aios_orchestrator_gateway.remote_tool_server import RemoteToolServerConfig
RemoteToolServerConfig.from_file(Path(sys.argv[1]))
print("REMOTE_TOOL_CONFIG_VALIDATE=PASS")
PY
PYTHONPATH="$RELEASE_DIR" python3 \
"$RELEASE_DIR/scripts/generate_orchestrator_tool_manifest.py" \
--transport-config "$CONFIG_ROOT/remote-tool.json" \
--output "$STATE_ROOT/orchestrator-tool-manifest.json"
systemctl enable --now aios-orchestrator-remote-tool.service
else
printf 'REMOTE_TOOL_CONFIGURATION=BLOCKED reason=REMOTE_CALLBACK_ENDPOINT_CONFIGURATION\n'
fi
printf 'AIOS_ORCHESTRATOR_INSTALL=PASS digest=%s previous=%s\n' \
"$SOURCE_DIGEST" "${PREVIOUS_TARGET:-NONE}"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "root required" >&2
exit 1
fi
SOURCE_ROOT=${1:?usage: install_automation_quorum_controller.sh SOURCE_ROOT VERSION}
VERSION=${2:?usage: install_automation_quorum_controller.sh SOURCE_ROOT VERSION}
[[ ${VERSION} =~ ^[0-9A-Za-z._-]+$ ]] || { echo "invalid version" >&2; exit 1; }
SOURCE_REAL=$(readlink -f -- "${SOURCE_ROOT}" 2>/dev/null || true)
[[ ${SOURCE_ROOT} = /* && -n ${SOURCE_REAL} && ${SOURCE_ROOT} == "${SOURCE_REAL}" && -d ${SOURCE_ROOT} && ! -L ${SOURCE_ROOT} ]] || {
echo "unsafe source root" >&2
exit 1
}
SOURCE_FILES=(
automation/executor_quorum_controller.py
automation/health_probe_common.py
automation/central_gpt_health_probe.py
automation/codex_executor_health_probe.py
automation/a0_unattended_loop_probe.py
schemas/automation-quorum-controller.schema.json
config/aios-automation-quorum-controller.example.json
config/aios-health-probes.example.json
systemd/aios-automation-quorum-controller.service
systemd/aios-automation-quorum-controller.timer
systemd/aios-automation-quorum-controller.sysusers
systemd/aios-automation-quorum-controller.tmpfiles
systemd/aios-central-gpt-health-probe.service
systemd/aios-central-gpt-health-probe.timer
systemd/aios-codex-executor-health-probe.service
systemd/aios-codex-executor-health-probe.timer
)
for relative in "${SOURCE_FILES[@]}"; do
[[ -f ${SOURCE_ROOT}/${relative} && ! -L ${SOURCE_ROOT}/${relative} ]] || {
echo "unsafe source file: ${relative}" >&2
exit 1
}
done
INSTALL_ROOT=/opt/aios-automation-quorum
VERSION_DIR=${INSTALL_ROOT}/releases/${VERSION}
[[ ! -e ${VERSION_DIR} ]] || { echo "version already installed" >&2; exit 1; }
install -d -o root -g root -m 0755 "${VERSION_DIR}/bin" "${VERSION_DIR}/share"
install -o root -g root -m 0755 "${SOURCE_ROOT}/automation/executor_quorum_controller.py" \
"${VERSION_DIR}/bin/executor-quorum-controller"
install -o root -g root -m 0644 "${SOURCE_ROOT}/automation/health_probe_common.py" \
"${VERSION_DIR}/bin/health_probe_common.py"
install -o root -g root -m 0755 "${SOURCE_ROOT}/automation/central_gpt_health_probe.py" \
"${VERSION_DIR}/bin/central-gpt-health-probe"
install -o root -g root -m 0755 "${SOURCE_ROOT}/automation/codex_executor_health_probe.py" \
"${VERSION_DIR}/bin/codex-executor-health-probe"
install -o root -g root -m 0755 "${SOURCE_ROOT}/automation/a0_unattended_loop_probe.py" \
"${VERSION_DIR}/bin/a0-unattended-loop-probe"
install -o root -g root -m 0644 "${SOURCE_ROOT}/schemas/automation-quorum-controller.schema.json" \
"${VERSION_DIR}/share/automation-quorum-controller.schema.json"
python3 -m py_compile "${VERSION_DIR}/bin/executor-quorum-controller" \
"${VERSION_DIR}/bin/health_probe_common.py" \
"${VERSION_DIR}/bin/central-gpt-health-probe" \
"${VERSION_DIR}/bin/codex-executor-health-probe"
python3 -m py_compile "${VERSION_DIR}/bin/a0-unattended-loop-probe"
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.service" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.timer" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-central-gpt-health-probe.service" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-central-gpt-health-probe.timer" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-codex-executor-health-probe.service" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-codex-executor-health-probe.timer" /etc/systemd/system/
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.sysusers" /usr/lib/sysusers.d/aios-automation-quorum-controller.conf
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.tmpfiles" /usr/lib/tmpfiles.d/aios-automation-quorum-controller.conf
systemd-sysusers /usr/lib/sysusers.d/aios-automation-quorum-controller.conf
systemd-tmpfiles --create /usr/lib/tmpfiles.d/aios-automation-quorum-controller.conf
install -d -o root -g aios-quorum -m 0750 /etc/aios-automation-quorum
if [[ ! -e /etc/aios-automation-quorum/controller.json ]]; then
install -o root -g aios-quorum -m 0640 \
"${SOURCE_ROOT}/config/aios-automation-quorum-controller.example.json" \
/etc/aios-automation-quorum/controller.json
fi
if [[ ! -e /etc/aios-automation-quorum/health-probes.json ]]; then
install -o root -g root -m 0600 \
"${SOURCE_ROOT}/config/aios-health-probes.example.json" \
/etc/aios-automation-quorum/health-probes.json
fi
PREVIOUS_TARGET=$(readlink -f "${INSTALL_ROOT}/current" 2>/dev/null || true)
RECEIPT=${VERSION_DIR}/install-receipt.json
python3 - "${VERSION_DIR}" "${VERSION}" "${PREVIOUS_TARGET}" "${RECEIPT}" <<'PY'
import hashlib, json, os, pathlib, sys, tempfile
root, version, previous, receipt = map(pathlib.Path, (sys.argv[1], sys.argv[2], sys.argv[3] or ".", sys.argv[4]))
files = {}
for item in sorted(root.rglob("*")):
if item.is_file() and not item.is_symlink():
files[str(item.relative_to(root))] = hashlib.sha256(item.read_bytes()).hexdigest()
payload = {"version": str(version), "previous_target": "" if str(previous) == "." else str(previous), "files": files}
fd, temp = tempfile.mkstemp(dir=str(receipt.parent), prefix=".receipt-")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
handle.write("\n")
os.chmod(temp, 0o600)
os.replace(temp, receipt)
PY
ln -sfn "${VERSION_DIR}" "${INSTALL_ROOT}/.current-${VERSION}"
mv -Tf "${INSTALL_ROOT}/.current-${VERSION}" "${INSTALL_ROOT}/current"
systemctl daemon-reload
echo "installed=${VERSION_DIR}"
echo "previous=${PREVIOUS_TARGET}"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from services.aios_orchestrator_gateway.config import Config
from services.aios_orchestrator_gateway.recovery import (
PendingDecisionRecovery,
RecoverySettings,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, required=True)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--plan", action="store_true")
mode.add_argument("--apply-one", action="store_true")
args = parser.parse_args()
config = Config.from_file(args.config)
recovery = PendingDecisionRecovery(RecoverySettings(
database_path=config.database_path,
contract_commit=config.contract_commit,
lease_seconds=config.lease_seconds,
max_retries=config.max_retries,
retry_after=config.retry_base_seconds,
))
if args.plan:
print(json.dumps(recovery.plan(), separators=(",", ":"), sort_keys=True))
else:
print(json.dumps({"processed_event_id": recovery.apply_one()}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+22 -52
View File
@@ -1,53 +1,23 @@
#!/usr/bin/env bash
set -Eeuo pipefail
VERSION="${1:?version required, e.g. V2R1C4}"
TARGET_OID="${2:?target_oid required}"
EVIDENCE_CLOSURE_OID="${3:?evidence_closure_oid required}"
APPROVAL_PLAN_ID="${4:?approval_plan_id required}"
WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
case "$VERSION" in
V[0-9]*R[0-9]*C[0-9]*) ;;
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
esac
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
echo "ERROR: target and evidence OIDs must be full lowercase 40-character OIDs" >&2
exit 64
}
done
[[ -n "$APPROVAL_PLAN_ID" ]] || {
echo "ERROR: approval_plan_id is required" >&2
exit 64
}
cd "$WORKSPACE"
BRANCH="$(git symbolic-ref --quiet --short HEAD)" || {
echo "ERROR: release must run on an isolated branch, not detached HEAD" >&2
exit 65
}
case "$BRANCH" in
main|master) echo "ERROR: direct main/master release writes are forbidden" >&2; exit 65 ;;
esac
python3 "$WORKSPACE/active-runtime/app/gitea_tag_publication.py" publish \
--workspace "$WORKSPACE" \
--remote "$TAG_REMOTE" \
--version "$VERSION" \
--target-oid "$TARGET_OID" \
--evidence-closure-oid "$EVIDENCE_CLOSURE_OID" \
--approval-plan-id "$APPROVAL_PLAN_ID"
git add -- "baseline/current.json" "versions/$VERSION.json"
if ! git diff --cached --quiet; then
git commit -m "release($VERSION): record Gitea-confirmed baseline"
fi
# No force, no main push, and no ignored failure.
git push "$BRANCH_REMOTE" "HEAD:refs/heads/$BRANCH"
echo "RELEASE_BASELINE_UPDATED $VERSION branch=$BRANCH"
set -euo pipefail
VERSION="${1:?version required, e.g. V1R1C0}"
MESSAGE="${2:-release $VERSION}"
if ! echo "$VERSION" | grep -Eq '^V[0-9]+R[0-9]+C[0-9]+$'; then echo "ERROR: version must match VxRxCx"; exit 1; fi
cd "${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
git checkout main
git pull origin main || true
git tag -a "$VERSION" -m "$MESSAGE" || true
git push origin "$VERSION" || true
python3 - <<PY
import json, datetime
from pathlib import Path
p=Path('baseline/current.json')
if p.exists(): d=json.loads(p.read_text())
else: p.parent.mkdir(exist_ok=True); d={}
d['active_version']='$VERSION'; d['active_tag']='$VERSION'; d['updated_at']=datetime.datetime.now(datetime.UTC).isoformat(); d['update_mode']='git_patch_only'
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
PY
git add baseline/current.json || true
git commit -m "release($VERSION): update active baseline" || true
git push origin main || true
echo "RELEASE_DONE $VERSION"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Submit an OpenClaw/Codex task lifecycle event over HTTP or a polled inbox."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from services.aios_orchestrator_gateway.models import Event
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared, read_secure_json, read_secure_text
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
p.add_argument("--project-key", required=True)
p.add_argument("--contract-commit", required=True)
p.add_argument("--event-id", required=True)
p.add_argument("--source-agent", required=True, choices=["openclaw", "codex", "gitea", "system"])
p.add_argument("--event-type", required=True)
p.add_argument("--work-unit-id", required=True)
p.add_argument(
"--status",
required=True,
choices=["READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"],
)
p.add_argument("--state-version", type=int, required=True)
p.add_argument("--implementation-commit")
p.add_argument("--result-commit")
p.add_argument("--blocker")
p.add_argument("--evidence-ref", action="append", default=[])
p.add_argument("--payload-file", type=Path)
p.add_argument("--endpoint")
p.add_argument("--token-file", type=Path)
p.add_argument("--inbox-dir", type=Path)
p.add_argument("--timeout-seconds", type=int, default=15)
return p
def main() -> int:
args = parser().parse_args()
if not args.endpoint and not args.inbox_dir:
raise SystemExit("either --endpoint or --inbox-dir is required")
if args.endpoint and not args.token_file:
raise SystemExit("--token-file is required with --endpoint")
payload = {}
if args.payload_file:
if not args.payload_file.is_absolute():
raise SystemExit("--payload-file must be absolute")
payload = read_secure_json(
args.payload_file, expected_uid=os.getuid(), max_bytes=262_144
)
if not isinstance(payload, dict):
raise SystemExit("payload root must be an object")
event = Event.from_dict(
{
"schema_version": 1,
"project_key": args.project_key,
"contract_commit": args.contract_commit,
"event_id": args.event_id,
"source_agent": args.source_agent,
"event_type": args.event_type,
"work_unit_id": args.work_unit_id,
"status": args.status,
"state_version": args.state_version,
"implementation_commit": args.implementation_commit,
"result_commit": args.result_commit,
"blocker": args.blocker,
"evidence_refs": args.evidence_ref,
"payload": payload,
}
)
encoded = event.canonical_json().encode()
http_error: Exception | None = None
if args.endpoint:
token = read_secure_text(
args.token_file,
expected_uid=None,
max_bytes=64_000,
)
request = urllib.request.Request(
args.endpoint.rstrip("/") + "/v1/orchestrator/events",
data=encoded,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Idempotency-Key": event.event_id,
"User-Agent": "aios-agent-event-reporter/0.2",
},
)
try:
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
response.read(65_536)
if response.status not in {200, 202}:
raise RuntimeError(f"unexpected HTTP {response.status}")
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=http")
return 0
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
http_error = exc
if args.inbox_dir:
pending = args.inbox_dir / "pending"
path = pending / f"{event.event_id}.json"
atomic_write_bytes_shared(
path,
(json.dumps(event.to_dict(), indent=2, sort_keys=True) + "\n").encode(),
mode=0o640,
group_writable_directory=True,
)
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=file_inbox")
return 0
print(
f"EVENT_SUBMIT=FAIL event_id={event.event_id} reason={type(http_error).__name__}",
file=sys.stderr,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
RECEIPT="$STATE_ROOT/install-receipt.json"
fail() {
printf 'AIOS_ORCHESTRATOR_ROLLBACK=BLOCKED reason=%s\n' "$1" >&2
exit 2
}
[[ "$(id -u)" -eq 0 ]] || fail OWNER_REQUIRED
[[ -f "$RECEIPT" ]] || fail RECEIPT_MISSING
[[ ! -L "$RECEIPT" ]] || fail RECEIPT_SYMLINK
[[ "$(stat -c '%u:%a' "$RECEIPT")" == "0:600" ]] || fail RECEIPT_PERMISSION
readarray -t VALUES < <(
python3 - "$RECEIPT" "$INSTALL_ROOT/releases" "$INSTALL_ROOT/current" <<'PY'
import json, os, stat, sys
from pathlib import Path
receipt = Path(sys.argv[1])
releases = Path(sys.argv[2]).resolve()
current_link = Path(sys.argv[3])
data = json.loads(receipt.read_text())
previous = data.get("previous_release")
current = data.get("current_release")
if not previous or not current:
raise SystemExit(2)
previous_path = Path(previous).resolve()
current_path = Path(current).resolve()
if previous_path.parent != releases or current_path.parent != releases:
raise SystemExit(2)
if not stat.S_ISDIR(os.lstat(previous_path).st_mode) or not stat.S_ISDIR(os.lstat(current_path).st_mode):
raise SystemExit(2)
for release in (previous_path, current_path):
if len(release.name) != 64 or any(ch not in "0123456789abcdef" for ch in release.name):
raise SystemExit(2)
digest_file = release / "SOURCE_DIGEST"
if digest_file.is_symlink() or not digest_file.is_file():
raise SystemExit(2)
if digest_file.read_text(encoding="utf-8").strip() != release.name:
raise SystemExit(2)
last = data.get("last_rollback")
if isinstance(last, dict) and last.get("to") == current and current_link.is_symlink():
if current_link.resolve() == current_path:
print("ALREADY_ROLLED_BACK")
raise SystemExit(0)
print(previous_path)
print(current_path)
PY
) || fail RECEIPT_INVALID
if [[ "${VALUES[0]:-}" == "ALREADY_ROLLED_BACK" ]]; then
echo "AIOS_ORCHESTRATOR_ROLLBACK=PASS result=NOOP_ALREADY_ROLLED_BACK"
exit 0
fi
[[ "${#VALUES[@]}" -eq 2 ]] || fail RECEIPT_INVALID
PREVIOUS="${VALUES[0]}"
CURRENT="${VALUES[1]}"
systemctl stop aios-orchestrator-gateway-poll.timer || true
systemctl stop aios-orchestrator-gateway-poll.service || true
systemctl stop aios-orchestrator-gateway.service || true
ln -s "$PREVIOUS" "$INSTALL_ROOT/.current-rollback-$$"
mv -Tf "$INSTALL_ROOT/.current-rollback-$$" "$INSTALL_ROOT/current"
systemctl start aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl start aios-orchestrator-gateway-poll.timer
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
python3 - "$RECEIPT" "$PREVIOUS" "$CURRENT" <<'PY'
import json, os, sys, tempfile
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text())
data["current_release"] = sys.argv[2]
data["previous_release"] = sys.argv[3]
data["last_rollback"] = {"from": sys.argv[3], "to": sys.argv[2]}
fd, temp = tempfile.mkstemp(prefix=".rollback-receipt-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temp, 0o600)
os.chown(temp, 0, 0)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temp)
except FileNotFoundError:
pass
PY
printf 'AIOS_ORCHESTRATOR_ROLLBACK=PASS current=%s previous=%s\n' "$PREVIOUS" "$CURRENT"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "root required" >&2
exit 1
fi
TARGET=${1:?usage: rollback_automation_quorum_controller.sh ABSOLUTE_RELEASE_PATH}
INSTALL_ROOT=/opt/aios-automation-quorum
TARGET_REAL=$(readlink -f -- "${TARGET}" 2>/dev/null || true)
[[ -n ${TARGET_REAL} && ${TARGET} == "${TARGET_REAL}" ]] || { echo "non-canonical or missing rollback target" >&2; exit 1; }
case ${TARGET_REAL} in
"${INSTALL_ROOT}"/releases/*) ;;
*) echo "target outside release root" >&2; exit 1 ;;
esac
[[ -d ${TARGET_REAL} && ! -L ${TARGET} ]] || { echo "invalid rollback target" >&2; exit 1; }
CURRENT=$(readlink -f "${INSTALL_ROOT}/current" 2>/dev/null || true)
if [[ ${CURRENT} == "${TARGET_REAL}" ]]; then
echo "already_current=${CURRENT}"
exit 0
fi
ln -sfn "${TARGET_REAL}" "${INSTALL_ROOT}/.rollback-current"
mv -Tf "${INSTALL_ROOT}/.rollback-current" "${INSTALL_ROOT}/current"
systemctl daemon-reload
systemctl try-restart aios-automation-quorum-controller.timer
echo "rolled_back_to=$(readlink -f "${INSTALL_ROOT}/current")"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Dependency-free, deterministic validation for the shipped controller config."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from urllib.parse import urlparse
REQUIRED = {
"project_key",
"contract_commit",
"database_path",
"file_inbox_path",
"health_receipts",
"blocker_retry_threshold",
"max_health_age_seconds",
"lease_ttl_seconds",
"gateway_event_url",
"gateway_token_file",
}
OPTIONAL = {"$schema", "default_workload", "git_ref_polling"}
WORKLOADS = {
"large_repetitive_infrastructure_monitoring",
"complex_code_security_architecture",
}
def fail(message: str) -> None:
raise ValueError(message)
def absolute_path(value: object, name: str) -> None:
if not isinstance(value, str) or not value.startswith("/") or "\x00" in value:
fail(f"{name} must be an absolute POSIX path")
def bounded_integer(value: object, name: str, minimum: int, maximum: int) -> None:
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
fail(f"{name} must be an integer in [{minimum}, {maximum}]")
def validate(config: object, schema: object) -> None:
if not isinstance(config, dict) or not isinstance(schema, dict):
fail("config and schema must be JSON objects")
if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
fail("unexpected JSON Schema dialect")
if schema.get("additionalProperties") is not False:
fail("schema must reject additional properties")
if set(schema.get("required", [])) != REQUIRED:
fail("schema required fields drifted from validator")
keys = set(config)
if not REQUIRED <= keys or keys - REQUIRED - OPTIONAL:
fail("missing or unexpected top-level config fields")
absolute_path(config["database_path"], "database_path")
absolute_path(config["file_inbox_path"], "file_inbox_path")
absolute_path(config["gateway_token_file"], "gateway_token_file")
project_key = config["project_key"]
if not isinstance(project_key, str) or not project_key or len(project_key) > 128:
fail("project_key is invalid")
commit = config["contract_commit"]
if not isinstance(commit, str) or len(commit) != 40 or any(c not in "0123456789abcdef" for c in commit):
fail("contract_commit must be a full lowercase Git OID")
receipts = config["health_receipts"]
if not isinstance(receipts, dict) or set(receipts) != {"central_gpt", "openclaw", "codex"}:
fail("health_receipts must contain exactly central_gpt, openclaw, and codex")
for subject, value in receipts.items():
absolute_path(value, f"health_receipts.{subject}")
git_polling = config.get("git_ref_polling")
if git_polling is not None:
if not isinstance(git_polling, dict) or set(git_polling) != {"repository", "refs"}:
fail("git_ref_polling must contain exactly repository and refs")
absolute_path(git_polling["repository"], "git_ref_polling.repository")
refs = git_polling["refs"]
if not isinstance(refs, list) or not refs or len(refs) != len(set(refs)):
fail("git_ref_polling.refs must be a non-empty unique list")
if any(not isinstance(ref, str) or not ref.startswith("refs/") or ".." in ref for ref in refs):
fail("git_ref_polling.refs contains an invalid ref")
if config.get("default_workload", "large_repetitive_infrastructure_monitoring") not in WORKLOADS:
fail("unsupported default_workload")
bounded_integer(config["blocker_retry_threshold"], "blocker_retry_threshold", 1, 20)
bounded_integer(config["max_health_age_seconds"], "max_health_age_seconds", 5, 300)
bounded_integer(config["lease_ttl_seconds"], "lease_ttl_seconds", 15, 3600)
url = config["gateway_event_url"]
parsed = urlparse(url) if isinstance(url, str) else None
if (
not parsed
or parsed.scheme != "http"
or parsed.hostname != "127.0.0.1"
or parsed.port != 18920
or parsed.path != "/v1/orchestrator/events"
or parsed.query
or parsed.fragment
):
fail("gateway_event_url must be the fixed loopback orchestrator endpoint")
def main() -> int:
if len(sys.argv) != 3:
print(f"usage: {Path(sys.argv[0]).name} CONFIG SCHEMA", file=sys.stderr)
return 2
with Path(sys.argv[1]).open("r", encoding="utf-8") as handle:
config = json.load(handle)
with Path(sys.argv[2]).open("r", encoding="utf-8") as handle:
schema = json.load(handle)
try:
validate(config, schema)
except ValueError as error:
print(f"schema validation failed: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG="${1:-/etc/aios-orchestrator-gateway/config.json}"
PORT="${PORT:-18920}"
python3 -m json.tool "$CONFIG" >/dev/null
systemd-analyze verify \
/etc/systemd/system/aios-orchestrator-gateway.service \
/etc/systemd/system/aios-orchestrator-gateway-poll.service \
/etc/systemd/system/aios-orchestrator-gateway-poll.timer
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
health="$(curl --fail --silent --show-error "http://127.0.0.1:${PORT}/healthz")"
python3 -c '
import json, sys
v=json.load(sys.stdin)
assert v["service"]=="aios-orchestrator-gateway"
assert v["status"]=="active"
assert v["polling"] is True
assert len(v["contract_commit"])==40
' <<<"$health"
systemctl start aios-orchestrator-gateway-poll.service
if journalctl -u aios-orchestrator-gateway.service -n 200 --no-pager \
| grep -Eqi 'Bearer |token=|api[_-]?key|secret='; then
echo "AIOS_ORCHESTRATOR_VERIFY=BLOCKED reason=SECRET_PATTERN_IN_LOG" >&2
exit 2
fi
echo "AIOS_ORCHESTRATOR_VERIFY=PASS"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
python3 -m py_compile "${ROOT}/automation/executor_quorum_controller.py"
python3 -m py_compile \
"${ROOT}/automation/health_probe_common.py" \
"${ROOT}/automation/central_gpt_health_probe.py" \
"${ROOT}/automation/codex_executor_health_probe.py" \
"${ROOT}/automation/a0_unattended_loop_probe.py"
python3 -m unittest discover -s "${ROOT}/tests" -p 'test_executor_quorum_controller.py' -v
python3 -m unittest discover -s "${ROOT}/tests" -p 'test_aios_health_probes.py' -v
python3 -m json.tool "${ROOT}/config/aios-automation-quorum-controller.example.json" >/dev/null
python3 -m json.tool "${ROOT}/schemas/automation-quorum-controller.schema.json" >/dev/null
python3 "${ROOT}/scripts/validate_automation_quorum_schema.py" \
"${ROOT}/config/aios-automation-quorum-controller.example.json" \
"${ROOT}/schemas/automation-quorum-controller.schema.json"
python3 -m py_compile "${ROOT}/scripts/validate_automation_quorum_schema.py"
bash -n "${ROOT}/scripts/install_automation_quorum_controller.sh"
bash -n "${ROOT}/scripts/rollback_automation_quorum_controller.sh"
bash -n "${ROOT}/scripts/verify_automation_quorum_controller.sh"
systemd-analyze verify \
"${ROOT}/systemd/aios-automation-quorum-controller.service" \
"${ROOT}/systemd/aios-automation-quorum-controller.timer" \
"${ROOT}/systemd/aios-central-gpt-health-probe.service" \
"${ROOT}/systemd/aios-central-gpt-health-probe.timer" \
"${ROOT}/systemd/aios-codex-executor-health-probe.service" \
"${ROOT}/systemd/aios-codex-executor-health-probe.timer"
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""Verify a Gitea credential rotation without printing either credential."""
from __future__ import annotations
import argparse
import json
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def token_at(path: Path, line: int) -> str:
lines = path.read_text(encoding="utf-8").splitlines()
if line < 1 or line > len(lines) or not lines[line - 1].strip():
raise RuntimeError("credential reference has no configured token line")
return lines[line - 1].strip()
def get(api: str, path: str, token: str) -> tuple[int, object | None]:
request = urllib.request.Request(
api.rstrip("/") + path,
headers={
"Accept": "application/json",
"Authorization": f"token {token}",
"User-Agent": "aios-gitops-credential-rotation-verifier/1",
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--old-token-file", type=Path, required=True)
parser.add_argument("--new-credential-file", type=Path, required=True)
parser.add_argument("--new-token-line", type=int, default=2)
parser.add_argument("--api", default="http://127.0.0.1:3000/api/v1")
parser.add_argument("--repository", default="aios/aios")
parser.add_argument("--version", default="V2R1C2")
parser.add_argument("--workspace", type=Path, required=True)
parser.add_argument("--remote", default="http://127.0.0.1:3000/aios/aios.git")
parser.add_argument(
"--branch",
default="patch/gitea-tag-publication-atomicity-20260723",
)
args = parser.parse_args()
old_token = token_at(args.old_token_file, 1)
new_token = token_at(args.new_credential_file, args.new_token_line)
repo_path = f"/repos/{args.repository}"
old_status, _ = get(args.api, repo_path, old_token)
new_status, _ = get(args.api, repo_path, new_token)
encoded = urllib.parse.quote(args.version, safe="")
ref_status, ref = get(
args.api,
f"{repo_path}/git/refs/tags/{encoded}",
new_token,
)
listing_status, listing = get(
args.api,
f"{repo_path}/tags?limit=50&page=1",
new_token,
)
refs = ref if isinstance(ref, list) else [ref]
exact = [
item
for item in refs
if isinstance(item, dict)
and item.get("ref") in {
f"refs/tags/{args.version}",
f"tags/{args.version}",
}
]
tag_oid = (
str(((exact[0].get("object") or {}).get("sha")) or "").lower()
if len(exact) == 1
else None
)
tag_status, tag = (
get(args.api, f"{repo_path}/git/tags/{tag_oid}", new_token)
if tag_oid
else (0, None)
)
peeled_target = (
str(((tag.get("object") or {}).get("sha")) or "").lower()
if isinstance(tag, dict)
else None
)
listing_target = None
if isinstance(listing, list):
for item in listing:
if item.get("name") == args.version:
listing_target = str(
((item.get("commit") or {}).get("sha")) or ""
).lower()
break
remote = subprocess.run(
[
"git",
"-c",
f"safe.directory={args.workspace}",
"-c",
"credential.helper=!/usr/local/libexec/aios-gitea-credential",
"-c",
"credential.useHttpPath=true",
"-C",
str(args.workspace),
"ls-remote",
args.remote,
f"refs/heads/{args.branch}",
],
text=True,
capture_output=True,
)
remote_oid = (
remote.stdout.split()[0].lower()
if remote.returncode == 0 and remote.stdout.split()
else None
)
result = {
"old_token_repository_http": old_status,
"new_token_repository_http": new_status,
"git_ls_remote_oid": remote_oid,
"gitea_ref_http": ref_status,
"gitea_tag_oid": tag_oid,
"gitea_tag_object_http": tag_status,
"gitea_peeled_target_oid": peeled_target,
"gitea_listing_http": listing_status,
"gitea_listing_target_oid": listing_target,
"credentials_printed": False,
}
print(json.dumps(result, indent=2, sort_keys=True))
passed = (
old_status in {401, 403}
and new_status == 200
and remote_oid is not None
and ref_status == 200
and tag_oid is not None
and tag_status == 200
and peeled_target is not None
and listing_status == 200
and listing_target is not None
)
return 0 if passed else 2
if __name__ == "__main__":
raise SystemExit(main())
+652
View File
@@ -0,0 +1,652 @@
#!/usr/bin/env python3
"""Hardened WeChat outbound backend for AIOS.
Only the dedicated UCC adapter is a supported send backend. OpenClaw channel
discovery is retained for diagnostics, but generic OpenClaw chat or agent
delivery is never promoted to WeChat delivery.
"""
from __future__ import annotations
import json
import os
import re
import selectors
import signal
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional, Tuple
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
WECHAT_ACCOUNT_ID = os.environ.get("AIOS_WECHAT_ACCOUNT_ID", "")
WECHAT_CHANNEL = os.environ.get("AIOS_WECHAT_CHANNEL", "")
SEND_BACKEND = os.environ.get("AIOS_WECHAT_SEND_BACKEND", "")
UCC_ADAPTER_URL = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_URL", "http://127.0.0.1:18800")
UCC_ADAPTER_TOKEN = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_TOKEN") or os.environ.get(
"AIOS_WECHAT_QR_ADAPTER_TOKEN", ""
)
UCC_SEND_PATH = os.environ.get("AIOS_UCC_WECHAT_SEND_PATH", "/api/internal/wechat/send")
_allowed_origins = os.environ.get("AIOS_UCC_WECHAT_ALLOWED_ORIGINS", "").strip()
UCC_ADAPTER_ALLOWED_ORIGINS = tuple(
origin.strip() for origin in _allowed_origins.split(",") if origin.strip()
) or (
"http://127.0.0.1:18800",
"http://[::1]:18800",
)
MAX_TARGET_BYTES = 512
MAX_CONTENT_BYTES = 32 * 1024
MAX_IDENTIFIER_BYTES = 512
MAX_METADATA_BYTES = 8 * 1024
MAX_METADATA_DEPTH = 32
MAX_METADATA_NODES = 1024
MAX_RESPONSE_BYTES = 64 * 1024
MAX_ENDPOINT_BYTES = 256
_DEFAULT_TIMEOUT_SECONDS = 10
_MAX_TIMEOUT_SECONDS = 30
_LEGACY_SEND_PATH = "/internal/channels/wechat/send"
_SAFE_PATH = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$")
_MAX_PATH_DECODE_ROUNDS = 4
_OPENCLAW_TIMEOUT_RETURN_CODE = 124
_OPENCLAW_OUTPUT_LIMIT_RETURN_CODE = 125
_OPENCLAW_SPAWN_ERROR_RETURN_CODE = 126
def _bounded_timeout(value: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return _DEFAULT_TIMEOUT_SECONDS
return min(max(parsed, 1), _MAX_TIMEOUT_SECONDS)
REQUEST_TIMEOUT_SECONDS = _bounded_timeout(os.environ.get("AIOS_UCC_WECHAT_TIMEOUT_SECONDS", "10"))
class BackendResult:
def __init__(self, ok: bool, backend: str, status: str, data: Dict[str, Any]) -> None:
self.ok = ok
self.backend = backend
self.status = status
self.data = data
def to_dict(self) -> Dict[str, Any]:
return {"ok": self.ok, "backend": self.backend, "status": self.status, **self.data}
class _BackendProtocolError(Exception):
"""Internal marker whose details must never cross the module boundary."""
def _openclaw_base_args() -> List[str]:
"""Return the OpenClaw 2026.6.11-compatible command prefix.
Gateway/auth global flags are not supported by the installed CLI and would
also expose credentials through the process argument vector.
"""
return [OPENCLAW_BIN]
def _terminate_process_tree(process: Any) -> None:
if os.name == "posix":
try:
os.killpg(process.pid, signal.SIGTERM)
except OSError:
pass
try:
process.wait(timeout=0.5)
except (OSError, subprocess.TimeoutExpired):
pass
time.sleep(0.05)
try:
os.killpg(process.pid, signal.SIGKILL)
except OSError:
pass
try:
process.wait(timeout=0.5)
except (OSError, subprocess.TimeoutExpired):
pass
return
if process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=1)
return
except (OSError, subprocess.TimeoutExpired):
pass
try:
process.kill()
process.wait(timeout=1)
except (OSError, subprocess.TimeoutExpired):
pass
def _run_openclaw(
args: List[str], timeout: int = 15
) -> subprocess.CompletedProcess[str]:
bounded_timeout = min(max(int(timeout), 1), _MAX_TIMEOUT_SECONDS)
popen_options: Dict[str, Any] = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
}
if os.name == "posix":
popen_options["start_new_session"] = True
elif hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
try:
process = subprocess.Popen(args, **popen_options)
except (OSError, ValueError):
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
selector = selectors.DefaultSelector()
streams = {"stdout": process.stdout, "stderr": process.stderr}
buffers = {"stdout": bytearray(), "stderr": bytearray()}
try:
for name, stream in streams.items():
if stream is not None:
selector.register(stream, selectors.EVENT_READ, name)
except (OSError, ValueError):
selector.close()
_terminate_process_tree(process)
for stream in streams.values():
if stream is not None:
stream.close()
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
deadline = time.monotonic() + bounded_timeout
total_bytes = 0
return_code: Optional[int] = None
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
return_code = _OPENCLAW_TIMEOUT_RETURN_CODE
break
events = selector.select(min(remaining, 0.1))
for key, _mask in events:
allowance = MAX_RESPONSE_BYTES - total_bytes
try:
chunk = os.read(key.fileobj.fileno(), min(8192, allowance + 1))
except OSError:
return_code = _OPENCLAW_SPAWN_ERROR_RETURN_CODE
break
if not chunk:
selector.unregister(key.fileobj)
key.fileobj.close()
continue
if len(chunk) > allowance:
return_code = _OPENCLAW_OUTPUT_LIMIT_RETURN_CODE
break
buffers[key.data].extend(chunk)
total_bytes += len(chunk)
if return_code is not None:
break
finally:
selector.close()
if return_code is not None:
_terminate_process_tree(process)
for stream in streams.values():
if stream is not None and not stream.closed:
stream.close()
return subprocess.CompletedProcess(args, return_code, "", "")
try:
completed_return_code = process.wait(timeout=1)
except subprocess.TimeoutExpired:
_terminate_process_tree(process)
return subprocess.CompletedProcess(args, _OPENCLAW_TIMEOUT_RETURN_CODE, "", "")
stdout = buffers["stdout"].decode("utf-8", errors="replace")
stderr = buffers["stderr"].decode("utf-8", errors="replace")
return subprocess.CompletedProcess(args, completed_return_code, stdout, stderr)
def _json_dict(text: str) -> Dict[str, Any]:
try:
value = json.loads(text or "{}")
except (RecursionError, TypeError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def discover_openclaw_wechat_channel() -> Optional[str]:
"""Discover an explicitly typed WeChat/Weixin channel for diagnostics."""
if WECHAT_CHANNEL:
return WECHAT_CHANNEL
try:
proc = _run_openclaw(_openclaw_base_args() + ["channels", "list", "--json"], timeout=15)
except (OSError, subprocess.SubprocessError, ValueError):
return None
if proc.returncode != 0:
return None
data = _json_dict(proc.stdout[:MAX_RESPONSE_BYTES])
candidates: List[str] = []
def walk(value: Any) -> None:
if isinstance(value, dict):
name = value.get("name") or value.get("channel") or value.get("id")
kind = value.get("type") or value.get("kind")
if isinstance(name, str) and isinstance(kind, str):
label = f"{name} {kind}".lower()
if "wechat" in label or "weixin" in label:
candidates.append(name)
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(data)
return candidates[0] if candidates else None
def _auth_headers() -> Dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if UCC_ADAPTER_TOKEN:
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
return headers
def _safe_path(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
path = value if value.startswith("/") else "/" + value
try:
encoded_path = path.encode("utf-8")
except UnicodeEncodeError:
return None
if len(encoded_path) > MAX_ENDPOINT_BYTES:
return None
if not _SAFE_PATH.fullmatch(path):
return None
decoded = path
for _ in range(_MAX_PATH_DECODE_ROUNDS):
try:
next_decoded = urllib.parse.unquote_to_bytes(decoded).decode("utf-8")
except UnicodeDecodeError:
return None
if next_decoded == decoded:
break
decoded = next_decoded
if "%" in decoded or "\\" in decoded or "\x00" in decoded:
return None
if not _SAFE_PATH.fullmatch(decoded):
return None
if any(segment in {".", ".."} for segment in decoded.split("/")):
return None
return path
def _unique_paths(paths: List[str]) -> List[str]:
out: List[str] = []
for candidate in paths:
path = _safe_path(candidate)
if path and path not in out:
out.append(path)
if len(out) == 2:
break
return out
def _ucc_send_paths() -> List[str]:
canonical = _safe_path(UCC_SEND_PATH) or "/api/internal/wechat/send"
return _unique_paths([canonical, _LEGACY_SEND_PATH])
def _canonical_origin(value: Any) -> Optional[str]:
if not isinstance(value, str) or not value or len(value) > 2048:
return None
if "\\" in value or any(ord(character) < 32 for character in value):
return None
try:
parsed = urllib.parse.urlsplit(value)
port = parsed.port
except (UnicodeError, ValueError):
return None
scheme = parsed.scheme.lower()
if scheme not in {"http", "https"} or not parsed.hostname:
return None
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
return None
try:
host = parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower()
except UnicodeError:
return None
if not host:
return None
if port is None:
port = 80 if scheme == "http" else 443
rendered_host = f"[{host}]" if ":" in host else host
return f"{scheme}://{rendered_host}:{port}"
def _adapter_base_url() -> Optional[str]:
origin = _canonical_origin(UCC_ADAPTER_URL)
allowed = {
canonical
for canonical in (_canonical_origin(value) for value in UCC_ADAPTER_ALLOWED_ORIGINS)
if canonical
}
return origin if origin and origin in allowed else None
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(
self,
req: Any,
fp: Any,
code: int,
msg: str,
headers: Any,
newurl: str,
) -> None:
del req, fp, code, msg, headers, newurl
return None
def _open_no_redirect(request: urllib.request.Request, timeout: int) -> Any:
opener = urllib.request.build_opener(_NoRedirectHandler())
return opener.open(request, timeout=timeout)
def ucc_adapter_alive() -> bool:
base_url = _adapter_base_url()
if not base_url:
return False
request = urllib.request.Request(
base_url + "/health", headers=_auth_headers(), method="GET"
)
try:
with _open_no_redirect(request, timeout=min(5, REQUEST_TIMEOUT_SECONDS)) as response:
return 200 <= int(response.status) < 300
except (OSError, ValueError, urllib.error.URLError):
return False
def select_backend() -> Dict[str, Any]:
explicit = SEND_BACKEND.strip().lower()
if explicit:
if explicit == "ucc-adapter":
return {"backend": "ucc-adapter", "reason": "explicit"}
return {"backend": "unavailable", "reason": "unsupported_backend"}
if ucc_adapter_alive():
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
return {"backend": "unavailable", "reason": "ucc_adapter_unavailable"}
def _post_ucc(path: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
base_url = _adapter_base_url()
safe_path = _safe_path(path)
if not base_url or not safe_path:
raise _BackendProtocolError("invalid adapter endpoint")
try:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
except (TypeError, ValueError) as exc:
raise _BackendProtocolError("invalid request payload") from exc
request = urllib.request.Request(
base_url + safe_path,
data=body,
headers=_auth_headers(),
method="POST",
)
with _open_no_redirect(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
raw = response.read(MAX_RESPONSE_BYTES + 1)
if len(raw) > MAX_RESPONSE_BYTES:
raise _BackendProtocolError("adapter response too large")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise _BackendProtocolError("adapter response encoding invalid") from exc
data = _json_dict(text)
if not data:
raise _BackendProtocolError("adapter response invalid")
return int(response.status), data
def _ucc_response_sent(data: Dict[str, Any]) -> bool:
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == "sent"
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool = False) -> bool:
expected_status = "dry_run" if dry_run else "sent"
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == expected_status
def _utf8_size(value: str) -> Optional[int]:
try:
return len(value.encode("utf-8"))
except UnicodeEncodeError:
return None
def _valid_identifier(value: Any, required: bool = False) -> bool:
if not isinstance(value, str):
return False
if required and not value.strip():
return False
if not value:
return not required
if len(value) > MAX_IDENTIFIER_BYTES:
return False
size = _utf8_size(value)
return size is not None and size <= MAX_IDENTIFIER_BYTES and not any(
ord(character) < 32 or ord(character) == 127 for character in value
)
def _validate_metadata(metadata: Any) -> Optional[str]:
if not isinstance(metadata, dict):
return "invalid_input"
nodes = 0
stack: List[Tuple[Any, int]] = [(metadata, 0)]
while stack:
value, depth = stack.pop()
nodes += 1
if depth > MAX_METADATA_DEPTH or nodes > MAX_METADATA_NODES:
return "input_too_large"
if isinstance(value, dict):
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
return "input_too_large"
for key, child in value.items():
if (
not isinstance(key, str)
or len(key) > MAX_METADATA_BYTES
or _utf8_size(key) is None
):
return "invalid_input"
stack.append((child, depth + 1))
elif isinstance(value, list):
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
return "input_too_large"
stack.extend((child, depth + 1) for child in value)
elif isinstance(value, str):
if len(value) > MAX_METADATA_BYTES:
return "input_too_large"
if _utf8_size(value) is None:
return "invalid_input"
elif value is not None and not isinstance(value, (bool, int, float)):
return "invalid_input"
try:
encoded = json.dumps(
metadata,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
except (RecursionError, TypeError, UnicodeEncodeError, ValueError):
return "invalid_input"
return "input_too_large" if len(encoded) > MAX_METADATA_BYTES else None
def _validate_payload(
target: Any,
content: Any,
account_id: Any,
message_id: Any,
metadata: Any,
dry_run: Any,
) -> Optional[str]:
if not isinstance(target, str) or not isinstance(content, str):
return "invalid_input"
if not target.strip() or not content.strip() or not isinstance(dry_run, bool):
return "invalid_input"
if any(ord(character) < 32 or ord(character) == 127 for character in target):
return "invalid_input"
if "\x00" in content or not _valid_identifier(account_id) or not _valid_identifier(message_id):
return "invalid_input"
if len(target) > MAX_TARGET_BYTES or len(content) > MAX_CONTENT_BYTES:
return "input_too_large"
target_size = _utf8_size(target)
content_size = _utf8_size(content)
if target_size is None or content_size is None:
return "invalid_input"
if target_size > MAX_TARGET_BYTES or content_size > MAX_CONTENT_BYTES:
return "input_too_large"
return _validate_metadata(metadata)
def _safe_provider_message_id(value: Any) -> Optional[str]:
if not isinstance(value, str) or not value:
return None
if len(value) > MAX_IDENTIFIER_BYTES:
return None
size = _utf8_size(value)
if size is None or size > MAX_IDENTIFIER_BYTES:
return None
if any(ord(character) < 32 or ord(character) == 127 for character in value):
return None
return value
def send_ucc_adapter(
target: str,
content: str,
account_id: str = "",
message_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
dry_run: bool = False,
) -> BackendResult:
effective_account = account_id or WECHAT_ACCOUNT_ID
effective_metadata: Any = {} if metadata is None else metadata
error_code = _validate_payload(
target,
content,
effective_account,
message_id,
effective_metadata,
dry_run,
)
if error_code:
return BackendResult(False, "ucc-adapter", "failed", {"code": error_code})
payload = {
"target": target,
"content": content,
"account_id": effective_account,
"message_id": message_id,
"metadata": effective_metadata,
"dry_run": dry_run,
}
last_endpoint: Optional[str] = None
for path in _ucc_send_paths():
last_endpoint = path
try:
http_status, data = _post_ucc(path, payload)
except urllib.error.HTTPError as exc:
if exc.code in {404, 405}:
continue
return BackendResult(
False,
"ucc-adapter",
"failed",
{"code": "ucc_adapter_http_error", "http_status": int(exc.code), "endpoint": path},
)
except (OSError, ValueError, urllib.error.URLError, _BackendProtocolError):
return BackendResult(
False,
"ucc-adapter",
"failed",
{"code": "ucc_adapter_unavailable", "endpoint": path},
)
except Exception:
# Fail closed without returning exception text, type, or chained cause.
return BackendResult(
False,
"ucc-adapter",
"failed",
{"code": "ucc_adapter_unavailable", "endpoint": path},
)
if not (200 <= http_status < 300):
return BackendResult(
False,
"ucc-adapter",
"failed",
{"code": "ucc_adapter_http_error", "http_status": http_status, "endpoint": path},
)
if not _ucc_result_is_ok(data, dry_run):
return BackendResult(
False,
"ucc-adapter",
"failed",
{"code": "ucc_adapter_rejected", "http_status": http_status, "endpoint": path},
)
confirmed_status = "dry_run" if dry_run else "sent"
result_data: Dict[str, Any] = {
"code": confirmed_status,
"http_status": http_status,
"endpoint": path,
}
provider_message_id = _safe_provider_message_id(data.get("provider_message_id"))
if provider_message_id:
result_data["provider_message_id"] = provider_message_id
return BackendResult(True, "ucc-adapter", confirmed_status, result_data)
data: Dict[str, Any] = {"code": "ucc_adapter_unavailable"}
if last_endpoint:
data["endpoint"] = last_endpoint
return BackendResult(False, "ucc-adapter", "failed", data)
def send_openclaw_channel(
target: str,
content: str,
account_id: str = "",
message_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
dry_run: bool = False,
) -> BackendResult:
del target, content, account_id, message_id, metadata, dry_run
return BackendResult(
False,
"openclaw-channel",
"failed",
{"code": "unsupported_backend"},
)
def send_message(
target: str,
content: str,
account_id: str = "",
message_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
dry_run: bool = False,
) -> Dict[str, Any]:
selected = select_backend()
if selected.get("backend") == "ucc-adapter":
return send_ucc_adapter(
target, content, account_id, message_id, metadata, dry_run
).to_dict()
code = "unsupported_backend" if selected.get("reason") == "unsupported_backend" else "wechat_outbound_backend_unavailable"
return BackendResult(False, "unavailable", "failed", {"code": code}).to_dict()
if __name__ == "__main__":
print(json.dumps(select_backend(), ensure_ascii=False, separators=(",", ":")))
@@ -1,38 +0,0 @@
{
"exposure_window": {
"detected_at": "2026-07-23T13:40:23+08:00",
"revoked_at": "2026-07-23T13:40:56+08:00",
"start": "2026-07-23T12:00:00+08:00",
"start_accuracy": "bounded approximation; precise platform tool-log timestamp unavailable"
},
"gitea_tag_read_validation": {
"listing_target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"peeled_target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"tag_mutation_performed": false,
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
"version": "V2R1C2"
},
"impact_scope": "Gitea repository write access for gitops-bot; no evidence of unauthorized use observed during rotation checks",
"incident_id": "GITEA-TOKEN-33-EXPOSURE-20260723",
"log_handling": {
"credential_values_in_record": false,
"reason": "formal audit/tool logs must not be deleted",
"server_redacted_record_mode": "0640 root:gitops",
"source_tool_log_deleted": false
},
"new_credential_reference": "/etc/aios-gitops/credentials/gitea-git-http",
"new_token_validation": {
"after_revoke_repository_http": 200,
"before_revoke_repository_http": 200,
"git_ls_remote_oid": "54093d15437ce0af31acf6feeeffacf6626a59cd",
"idempotent_branch_push": true
},
"old_token_validation": {
"after_revoke_repository_http": 401,
"before_revoke_repository_http": 200
},
"production_tag_operation": "NOT_EXECUTED",
"replacement_token_record_id": 50,
"scope": "write:repository",
"token_id": 33
}
+1
View File
@@ -0,0 +1 @@
"""AIOS services namespace."""
+5
View File
@@ -0,0 +1,5 @@
"""Reliable, fail-closed Codex command watcher."""
from .watcher import CommandWatcher, Config, load_config
__all__ = ["CommandWatcher", "Config", "load_config"]
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import argparse
import sys
from .watcher import CommandWatcher, load_config
def main() -> int:
parser = argparse.ArgumentParser(description="Claim and execute Codex work units")
parser.add_argument("--config", required=True)
parser.add_argument("--once", action="store_true", help="process one scan and exit")
args = parser.parse_args()
watcher = CommandWatcher(load_config(args.config))
return watcher.run_once() if args.once else watcher.run_forever()
if __name__ == "__main__":
sys.exit(main())
+480
View File
@@ -0,0 +1,480 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import re
import sqlite3
import stat
import subprocess
import time
from typing import Any, Callable
import urllib.error
import urllib.parse
import urllib.request
import uuid
OID_RE = re.compile(r"^[0-9a-f]{40}$")
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
TERMINAL = {"COMPLETED", "DEAD_LETTER", "REJECTED"}
class WatcherError(RuntimeError):
pass
@dataclass(frozen=True)
class Config:
active_dir: Path
completed_dir: Path
dead_letter_dir: Path
database_path: Path
audit_log_path: Path
gateway_endpoint: str
gateway_token_file: Path
contract_commit: str
lease_seconds: int
retry_seconds: int
max_attempts: int
poll_seconds: float
executor_path: Path | None
executor_args: tuple[str, ...]
executor_timeout_seconds: int
def _absolute(value: Any, key: str) -> Path:
if not isinstance(value, str) or not value or not Path(value).is_absolute():
raise WatcherError(f"invalid_{key}")
return Path(value)
def _secure_regular(path: Path, *, writable_mask: int = 0o022) -> os.stat_result:
info = os.lstat(path)
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise WatcherError(f"unsafe_file:{path}")
if stat.S_IMODE(info.st_mode) & writable_mask:
raise WatcherError(f"unsafe_mode:{path}")
return info
def _read_secure_text(
path: Path,
*,
writable_mask: int = 0o022,
max_bytes: int,
allowed_owners: set[int] | None = None,
) -> str:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & writable_mask:
raise WatcherError(f"unsafe_file:{path}")
if allowed_owners is not None and info.st_uid not in allowed_owners:
raise WatcherError(f"owner_mismatch:{path}")
if info.st_size > max_bytes:
raise WatcherError(f"file_too_large:{path}")
chunks: list[bytes] = []
total = 0
while True:
chunk = os.read(fd, min(65_536, max_bytes + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > max_bytes:
raise WatcherError(f"file_too_large:{path}")
return b"".join(chunks).decode("utf-8")
finally:
os.close(fd)
def load_config(path_value: str) -> Config:
path = _absolute(path_value, "config_path")
raw = json.loads(_read_secure_text(path, max_bytes=131_072, allowed_owners={0, os.geteuid()}))
expected = {
"active_dir", "completed_dir", "dead_letter_dir", "database_path",
"audit_log_path", "gateway_endpoint", "gateway_token_file",
"contract_commit", "lease_seconds", "retry_seconds", "max_attempts",
"poll_seconds", "executor_path", "executor_args",
"executor_timeout_seconds",
}
if not isinstance(raw, dict) or set(raw) != expected:
raise WatcherError("invalid_config_fields")
contract = raw["contract_commit"]
if not isinstance(contract, str) or not OID_RE.fullmatch(contract):
raise WatcherError("invalid_contract_commit")
endpoint = raw["gateway_endpoint"]
if not isinstance(endpoint, str) or not endpoint.startswith("http://127.0.0.1:") or not endpoint.endswith("/v1/orchestrator/events"):
raise WatcherError("gateway_endpoint_must_be_loopback")
executor_raw = raw["executor_path"]
executor = None if executor_raw is None else _absolute(executor_raw, "executor_path")
executor_args = raw["executor_args"]
if not isinstance(executor_args, list) or any(not isinstance(x, str) or not x for x in executor_args):
raise WatcherError("invalid_executor_args")
ints = ("lease_seconds", "retry_seconds", "max_attempts", "executor_timeout_seconds")
if any(not isinstance(raw[x], int) or raw[x] <= 0 for x in ints):
raise WatcherError("invalid_positive_integer")
if not isinstance(raw["poll_seconds"], (int, float)) or raw["poll_seconds"] <= 0:
raise WatcherError("invalid_poll_seconds")
return Config(
active_dir=_absolute(raw["active_dir"], "active_dir"),
completed_dir=_absolute(raw["completed_dir"], "completed_dir"),
dead_letter_dir=_absolute(raw["dead_letter_dir"], "dead_letter_dir"),
database_path=_absolute(raw["database_path"], "database_path"),
audit_log_path=_absolute(raw["audit_log_path"], "audit_log_path"),
gateway_endpoint=endpoint,
gateway_token_file=_absolute(raw["gateway_token_file"], "gateway_token_file"),
contract_commit=contract,
lease_seconds=raw["lease_seconds"], retry_seconds=raw["retry_seconds"],
max_attempts=raw["max_attempts"], poll_seconds=float(raw["poll_seconds"]),
executor_path=executor, executor_args=tuple(executor_args),
executor_timeout_seconds=raw["executor_timeout_seconds"],
)
def canonical_digest(value: dict[str, Any]) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def validate_command(value: Any, expected_contract: str) -> dict[str, Any]:
expected = {
"schema_version", "command_id", "project_key", "work_unit_id",
"contract_commit", "state_version", "execution",
}
if not isinstance(value, dict) or set(value) != expected or value.get("schema_version") != 1:
raise WatcherError("invalid_command_fields")
for key in ("command_id", "project_key", "work_unit_id"):
if not isinstance(value[key], str) or not ID_RE.fullmatch(value[key]):
raise WatcherError(f"invalid_{key}")
if value["contract_commit"] != expected_contract:
raise WatcherError("contract_commit_mismatch")
if not isinstance(value["state_version"], int) or value["state_version"] < 0:
raise WatcherError("invalid_state_version")
execution = value["execution"]
if not isinstance(execution, dict) or set(execution) != {"kind", "input"}:
raise WatcherError("invalid_execution")
if execution["kind"] not in {"acceptance_probe", "codex"}:
raise WatcherError("invalid_execution_kind")
if not isinstance(execution["input"], str) or len(execution["input"].encode()) > 65_536:
raise WatcherError("invalid_execution_input")
return value
class CommandWatcher:
def __init__(
self,
config: Config,
*,
clock: Callable[[], float] = time.time,
event_poster: Callable[[dict[str, Any]], None] | None = None,
executor: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
worker_id: str | None = None,
) -> None:
self.config = config
self.clock = clock
self.worker_id = worker_id or f"{os.uname().nodename}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
self._uses_gateway = event_poster is None
self.event_poster = event_poster or self._post_event
self.executor = executor or self._execute
self._prepare()
def _prepare(self) -> None:
for directory in (self.config.active_dir, self.config.completed_dir, self.config.dead_letter_dir):
info = os.lstat(directory)
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o002:
raise WatcherError(f"unsafe_directory:{directory}")
self.config.database_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.config.audit_log_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with self._db() as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute("""
CREATE TABLE IF NOT EXISTS commands (
command_id TEXT PRIMARY KEY, work_unit_id TEXT NOT NULL UNIQUE,
payload_sha256 TEXT NOT NULL,
source_path TEXT NOT NULL, status TEXT NOT NULL,
phase TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
lease_owner TEXT, lease_until REAL, next_attempt REAL NOT NULL DEFAULT 0,
result_json TEXT, start_event_json TEXT, end_event_json TEXT,
last_error TEXT, updated_at REAL NOT NULL
)
""")
def _db(self) -> sqlite3.Connection:
db = sqlite3.connect(self.config.database_path, timeout=5, isolation_level=None)
db.row_factory = sqlite3.Row
return db
def _audit(self, marker: str, command_id: str, **fields: Any) -> None:
safe = {k: v for k, v in fields.items() if k not in {"input", "token", "secret"}}
record = {"time": int(self.clock()), "marker": marker, "command_id": command_id, **safe}
fd = os.open(self.config.audit_log_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
try:
os.write(fd, (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
def _read_command(self, path: Path) -> tuple[dict[str, Any], str]:
raw = _read_secure_text(path, max_bytes=131_072, allowed_owners={0, os.geteuid()})
value = validate_command(json.loads(raw), self.config.contract_commit)
return value, canonical_digest(value)
def _register(self, command: dict[str, Any], digest: str, path: Path) -> str:
now = self.clock()
with self._db() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT payload_sha256,status FROM commands WHERE command_id=?", (command["command_id"],)).fetchone()
if row:
db.execute("COMMIT")
if row["payload_sha256"] != digest:
raise WatcherError("duplicate_command_payload_conflict")
return "duplicate_terminal" if row["status"] in TERMINAL else "existing"
prior_work_unit = db.execute(
"SELECT command_id FROM commands WHERE work_unit_id=?",
(command["work_unit_id"],),
).fetchone()
if prior_work_unit:
db.execute("COMMIT")
raise WatcherError("duplicate_work_unit")
db.execute(
"INSERT INTO commands(command_id,work_unit_id,payload_sha256,source_path,status,phase,updated_at) VALUES(?,?,?,?,?,?,?)",
(command["command_id"], command["work_unit_id"], digest, str(path), "PENDING", "START", now),
)
db.execute("COMMIT")
return "new"
def _claim(self, command_id: str) -> sqlite3.Row | None:
now = self.clock()
with self._db() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT * FROM commands WHERE command_id=?", (command_id,)).fetchone()
if not row or row["status"] in TERMINAL or row["next_attempt"] > now:
db.execute("COMMIT")
return None
if row["lease_until"] is not None and row["lease_until"] > now and row["lease_owner"] != self.worker_id:
db.execute("COMMIT")
return None
attempts = row["attempts"] + 1
db.execute(
"UPDATE commands SET status='CLAIMED',attempts=?,lease_owner=?,lease_until=?,updated_at=? WHERE command_id=?",
(attempts, self.worker_id, now + self.config.lease_seconds, now, command_id),
)
row = db.execute("SELECT * FROM commands WHERE command_id=?", (command_id,)).fetchone()
db.execute("COMMIT")
assert row is not None
self._audit("CODEX_AUTO_CLAIM", command_id, attempt=row["attempts"], worker=self.worker_id)
return row
def _update(self, command_id: str, *, status: str, phase: str, result: dict[str, Any] | None = None, error: str | None = None) -> None:
with self._db() as db:
release = status in TERMINAL
db.execute(
"UPDATE commands SET status=?,phase=?,result_json=COALESCE(?,result_json),last_error=?,lease_owner=CASE WHEN ? THEN NULL ELSE lease_owner END,lease_until=CASE WHEN ? THEN NULL ELSE lease_until END,updated_at=? WHERE command_id=?",
(status, phase, json.dumps(result, sort_keys=True) if result is not None else None, error, release, release, self.clock(), command_id),
)
def _retry_or_dead(self, command_id: str, path: Path, error: Exception) -> None:
message = f"{type(error).__name__}:{str(error)[:240]}"
with self._db() as db:
row = db.execute("SELECT attempts,phase FROM commands WHERE command_id=?", (command_id,)).fetchone()
attempts = row["attempts"] if row else self.config.max_attempts
if str(error) == "gateway_stale_state_version" and row:
column = "start_event_json" if row["phase"] == "START" else "end_event_json"
db.execute(f"UPDATE commands SET {column}=NULL WHERE command_id=?", (command_id,))
if attempts >= self.config.max_attempts:
db.execute("UPDATE commands SET status='DEAD_LETTER',last_error=?,lease_owner=NULL,lease_until=NULL,updated_at=? WHERE command_id=?", (message, self.clock(), command_id))
self._move(path, self.config.dead_letter_dir)
self._audit("DEAD_LETTER", command_id, error=message)
else:
db.execute("UPDATE commands SET status='RETRY',last_error=?,next_attempt=?,lease_owner=NULL,lease_until=NULL,updated_at=? WHERE command_id=?", (message, self.clock() + self.config.retry_seconds, self.clock(), command_id))
self._audit("RETRY_SCHEDULED", command_id, attempt=attempts, error=message)
def _event(self, command: dict[str, Any], event_type: str, status: str, suffix: str, result: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"schema_version": 1, "project_key": command["project_key"],
"contract_commit": command["contract_commit"],
"event_id": f"{command['command_id']}:{suffix}", "source_agent": "codex",
"event_type": event_type, "work_unit_id": command["work_unit_id"],
"status": status, "state_version": command["state_version"],
"implementation_commit": None, "result_commit": None, "blocker": None,
"evidence_refs": [], "payload": {"command_id": command["command_id"], "result": result or {}},
}
def _gateway_token(self) -> str:
token = _read_secure_text(
self.config.gateway_token_file,
writable_mask=0o027,
max_bytes=16_384,
allowed_owners={0, os.geteuid()},
)
if not token or "\n" in token or "\r" in token:
raise WatcherError("invalid_gateway_token")
return token
def _gateway_state_version(self, project_key: str, fallback: int) -> int:
state_url = self.config.gateway_endpoint.removesuffix("/events") + "/state/" + urllib.parse.quote(project_key, safe="")
request = urllib.request.Request(
state_url,
headers={"Authorization": f"Bearer {self._gateway_token()}"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
payload = json.loads(response.read(262_145))
version = payload.get("state_version")
if not isinstance(version, int) or version < 0:
raise WatcherError("invalid_gateway_state")
return version
except urllib.error.HTTPError as exc:
if exc.code == 404:
return fallback
raise WatcherError(f"gateway_state_http_{exc.code}") from exc
except urllib.error.URLError as exc:
raise WatcherError("gateway_state_unavailable") from exc
def _durable_event(self, command: dict[str, Any], column: str, event: dict[str, Any]) -> dict[str, Any]:
with self._db() as db:
row = db.execute(f"SELECT {column} FROM commands WHERE command_id=?", (command["command_id"],)).fetchone()
if row and row[column]:
return json.loads(row[column])
if self._uses_gateway:
event["state_version"] = self._gateway_state_version(command["project_key"], command["state_version"])
encoded = json.dumps(event, sort_keys=True, separators=(",", ":"))
with self._db() as db:
db.execute(f"UPDATE commands SET {column}=? WHERE command_id=? AND {column} IS NULL", (encoded, command["command_id"]))
saved = db.execute(f"SELECT {column} FROM commands WHERE command_id=?", (command["command_id"],)).fetchone()
if not saved or not saved[column]:
raise WatcherError("event_persistence_failed")
return json.loads(saved[column])
def _post_event(self, event: dict[str, Any]) -> None:
request = urllib.request.Request(
self.config.gateway_endpoint,
data=json.dumps(event, separators=(",", ":")).encode(),
headers={"Authorization": f"Bearer {self._gateway_token()}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
if response.status not in {200, 201, 202}:
raise WatcherError(f"gateway_http_{response.status}")
except urllib.error.HTTPError as exc:
body = exc.read(16_384)
try:
reason = json.loads(body).get("error")
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
reason = None
if exc.code == 409 and reason == "stale_state_version":
raise WatcherError("gateway_stale_state_version") from exc
raise WatcherError(f"gateway_http_{exc.code}") from exc
except urllib.error.URLError as exc:
raise WatcherError("gateway_unavailable") from exc
def _execute(self, command: dict[str, Any]) -> dict[str, Any]:
execution = command["execution"]
if execution["kind"] == "acceptance_probe":
return {"kind": "acceptance_probe", "input_sha256": hashlib.sha256(execution["input"].encode()).hexdigest(), "exit_code": 0}
assert self.config.executor_path is not None
proc = subprocess.run(
[str(self.config.executor_path), *self.config.executor_args],
input=execution["input"], text=True, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=self.config.executor_timeout_seconds,
check=False, env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
)
if proc.returncode != 0:
raise WatcherError(f"codex_executor_failed:{proc.returncode}")
return {"kind": "codex", "exit_code": 0}
def _preflight_execution(self, command: dict[str, Any]) -> None:
if command["execution"]["kind"] == "acceptance_probe":
return
if self.config.executor_path is None:
raise WatcherError("codex_executor_unavailable")
info = _secure_regular(self.config.executor_path)
if not info.st_mode & stat.S_IXUSR:
raise WatcherError("codex_executor_not_executable")
def _move(self, source: Path, target_dir: Path) -> None:
if not source.exists() and not source.is_symlink():
return
target = target_dir / source.name
if target.exists() or target.is_symlink():
target = target_dir / f"{source.stem}-{uuid.uuid4().hex[:8]}{source.suffix}"
os.replace(source, target)
def _reject_unregistered(self, path: Path, error: Exception) -> None:
command_id = path.stem[:128] if ID_RE.fullmatch(path.stem[:128]) else f"invalid-{uuid.uuid4().hex[:12]}"
self._audit("COMMAND_REJECTED", command_id, error=f"{type(error).__name__}:{str(error)[:240]}")
self._move(path, self.config.dead_letter_dir)
def process_path(self, path: Path) -> bool:
try:
command, digest = self._read_command(path)
registration = self._register(command, digest, path)
if registration == "duplicate_terminal":
self._move(path, self.config.completed_dir)
return False
row = self._claim(command["command_id"])
if row is None:
return False
phase = row["phase"]
result = json.loads(row["result_json"]) if row["result_json"] else None
if phase == "START":
start_event = self._durable_event(
command,
"start_event_json",
self._event(command, "work_unit_started", "RUNNING", "started"),
)
self.event_poster(start_event)
self._audit("TASK_STARTED", command["command_id"])
self._update(command["command_id"], status="PENDING", phase="EXECUTE")
phase = "EXECUTE"
if phase == "EXECUTE":
self._preflight_execution(command)
self._update(command["command_id"], status="CLAIMED", phase="EXECUTING")
phase = "EXECUTING"
result = self.executor(command)
self._audit("TASK_EXECUTED", command["command_id"], result=result)
self._update(command["command_id"], status="PENDING", phase="END", result=result)
phase = "END"
elif phase == "EXECUTING":
raise WatcherError("ambiguous_execution_not_repeated")
if phase == "END":
assert result is not None
end_event = self._durable_event(
command,
"end_event_json",
self._event(command, "work_unit_ended", "PASS", "ended", result),
)
self.event_poster(end_event)
self._audit("TASK_ENDED", command["command_id"])
self._audit("EVENT_POSTED", command["command_id"], event="work_unit_ended")
self._update(command["command_id"], status="COMPLETED", phase="DONE", result=result)
self._move(path, self.config.completed_dir)
return True
except Exception as exc:
try:
if isinstance(exc, WatcherError) and str(exc) in {"duplicate_command_payload_conflict", "duplicate_work_unit"}:
self._reject_unregistered(path, exc)
elif "command" in locals():
self._retry_or_dead(command["command_id"], path, exc)
else:
self._reject_unregistered(path, exc)
except Exception:
pass
return False
def run_once(self) -> int:
failures = 0
for path in sorted(self.config.active_dir.glob("*.json")):
if not self.process_path(path):
failures += 1
return 1 if failures else 0
def run_forever(self) -> int:
while True:
self.run_once()
time.sleep(self.config.poll_seconds)
@@ -0,0 +1,3 @@
"""Reliable central orchestration gateway for AIOS."""
__version__ = "0.2.0"
@@ -0,0 +1,34 @@
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from .config import Config
from .service import Runtime, serve
def main() -> int:
parser = argparse.ArgumentParser(description="AIOS Orchestrator Gateway")
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--log-level", default="INFO")
parser.add_argument(
"--poll-once",
action="store_true",
help="Run one inbox/model/outbox/OpenClaw synchronization polling cycle.",
)
args = parser.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
if args.poll_once:
runtime = Runtime(Config.from_file(args.config))
runtime.poll_once()
return 0
serve(args.config)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import stat
import subprocess
from typing import Any, Callable
import urllib.error
import urllib.request
from .models import Decision
GATEWAY_DECISION_URL = "http://127.0.0.1:18920/v1/orchestrator/decisions"
SECRET_REF_RE = re.compile(r"^secret://[A-Za-z0-9._/-]{1,480}$")
class CallbackBridgeError(RuntimeError):
def __init__(self, category: str, status_code: int) -> None:
super().__init__(category)
self.category = category
self.status_code = status_code
def _trusted_resolver(path: Path) -> Path:
if not path.is_absolute():
raise CallbackBridgeError("CALLBACK_CONFIGURATION_UNAVAILABLE", 503)
try:
info = path.lstat()
resolved = path.resolve(strict=True)
except OSError as exc:
raise CallbackBridgeError("CALLBACK_CONFIGURATION_UNAVAILABLE", 503) from exc
if (
resolved != path
or stat.S_ISLNK(info.st_mode)
or not stat.S_ISREG(info.st_mode)
or info.st_uid != 0
or stat.S_IMODE(info.st_mode) & 0o022
or not stat.S_IMODE(info.st_mode) & 0o111
):
raise CallbackBridgeError("CALLBACK_CONFIGURATION_UNAVAILABLE", 503)
return resolved
def resolve_secret_ref(secret_ref: str, resolver: Path) -> str:
if not isinstance(secret_ref, str) or not SECRET_REF_RE.fullmatch(secret_ref):
raise CallbackBridgeError("CALLBACK_CONFIGURATION_UNAVAILABLE", 503)
executable = _trusted_resolver(resolver)
request = json.dumps(
{"operation": "resolve", "secret_ref": secret_ref},
separators=(",", ":"),
sort_keys=True,
).encode()
try:
result = subprocess.run(
[str(executable)],
input=request,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise CallbackBridgeError("CALLBACK_CREDENTIAL_UNAVAILABLE", 503) from exc
token = result.stdout.removesuffix(b"\n")
if (
result.returncode != 0
or not token
or len(token) > 16_384
or b"\n" in token
or b"\r" in token
):
raise CallbackBridgeError("CALLBACK_CREDENTIAL_UNAVAILABLE", 503)
try:
return token.decode("utf-8")
except UnicodeDecodeError as exc:
raise CallbackBridgeError("CALLBACK_CREDENTIAL_UNAVAILABLE", 503) from exc
def submit_orchestration_decision(
payload: dict[str, Any],
*,
contract_commit: str,
secret_ref: str,
resolver: Path,
request_open: Callable[..., Any] = urllib.request.urlopen,
) -> dict[str, Any]:
try:
decision = Decision.from_dict(payload)
except Exception as exc:
raise CallbackBridgeError("INVALID_DECISION", 422) from exc
if decision.contract_commit != contract_commit:
raise CallbackBridgeError("CONTRACT_MISMATCH", 409)
token = resolve_secret_ref(secret_ref, resolver)
request = urllib.request.Request(
GATEWAY_DECISION_URL,
data=json.dumps(
decision.to_dict(), separators=(",", ":"), sort_keys=True
).encode(),
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Idempotency-Key": decision.event_id,
"User-Agent": "aios-chatgpt-actions/decision-bridge-v1",
},
)
try:
with request_open(request, timeout=10) as response:
raw = response.read(65_537)
if response.status != 202 or len(raw) > 65_536:
raise CallbackBridgeError("CALLBACK_HTTP_ERROR", 502)
except urllib.error.HTTPError as exc:
if exc.code == 409:
raise CallbackBridgeError("STALE_STATE", 409) from exc
raise CallbackBridgeError("CALLBACK_HTTP_ERROR", 502) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise CallbackBridgeError("CALLBACK_NETWORK_ERROR", 502) from exc
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise CallbackBridgeError("CALLBACK_RESPONSE_INVALID", 502) from exc
if (
not isinstance(value, dict)
or value.get("accepted") is not True
or not isinstance(value.get("state_version"), int)
):
raise CallbackBridgeError("CALLBACK_RESPONSE_INVALID", 502)
return {"accepted": True, "state_version": value["state_version"]}
@@ -0,0 +1,117 @@
from __future__ import annotations
from dataclasses import dataclass
import ipaddress
from typing import Any
from urllib.parse import urlparse
from .errors import ConfigurationError
from .models import DECISION_SCHEMA
TRANSPORT_MODES = {"remote_https_action", "secure_mcp_tunnel"}
FORBIDDEN_HOSTS = {"localhost", "localhost.localdomain"}
FORBIDDEN_MARKERS = {"replace_with_actions_host", "placeholder"}
def _https_endpoint(value: Any, *, public: bool) -> str:
if not isinstance(value, str) or not value or any(
marker in value.lower() for marker in FORBIDDEN_MARKERS
):
raise ConfigurationError("invalid_callback_endpoint")
parsed = urlparse(value)
host = (parsed.hostname or "").lower()
if (
parsed.scheme != "https"
or not host
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or host in FORBIDDEN_HOSTS
):
raise ConfigurationError("invalid_callback_endpoint")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and (
address.is_loopback
or address.is_private
or address.is_unspecified
or address.is_link_local
or address.is_multicast
or address.is_reserved
):
raise ConfigurationError("invalid_callback_endpoint")
if public and (address is None and ("." not in host or host.endswith((".local", ".internal")))):
raise ConfigurationError("callback_endpoint_not_public")
return value.rstrip("/")
@dataclass(frozen=True)
class CallbackTransport:
mode: str
endpoint: str
approved_mcp_tunnel_endpoints: tuple[str, ...]
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "CallbackTransport":
expected = {"mode", "endpoint", "approved_mcp_tunnel_endpoints"}
if not isinstance(value, dict) or set(value) != expected:
raise ConfigurationError("invalid_callback_transport_fields")
mode = value.get("mode")
if mode not in TRANSPORT_MODES:
raise ConfigurationError("invalid_callback_transport_mode")
approved = value.get("approved_mcp_tunnel_endpoints")
if not isinstance(approved, list) or any(not isinstance(x, str) for x in approved):
raise ConfigurationError("invalid_approved_mcp_tunnel_endpoints")
normalized_approved = tuple(_https_endpoint(item, public=False) for item in approved)
endpoint = _https_endpoint(value.get("endpoint"), public=mode == "remote_https_action")
if mode == "secure_mcp_tunnel" and endpoint not in normalized_approved:
raise ConfigurationError("unapproved_secure_mcp_tunnel_endpoint")
return cls(mode, endpoint, normalized_approved)
def action_openapi(self) -> dict[str, Any]:
if self.mode != "remote_https_action":
raise ConfigurationError("action_manifest_for_wrong_transport")
return {
"openapi": "3.1.0",
"info": {"title": "AIOS Orchestrator Decision Callback", "version": "1.1.0"},
"servers": [{"url": self.endpoint}],
"paths": {
"/v1/orchestrator/decisions": {
"post": {
"operationId": "submit_orchestration_decision",
"summary": "Submit one strict orchestration decision",
"security": [{"bearerAuth": []}],
"requestBody": {
"required": True,
"content": {"application/json": {"schema": DECISION_SCHEMA}},
},
"responses": {"202": {"description": "Accepted"}},
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {"type": "http", "scheme": "bearer"}
}
},
}
def mcp_tool(self) -> dict[str, Any]:
if self.mode != "secure_mcp_tunnel":
raise ConfigurationError("mcp_manifest_for_wrong_transport")
return {
"name": "submit_orchestration_decision",
"description": "Validate and submit one AIOS orchestration decision.",
"inputSchema": DECISION_SCHEMA,
"annotations": {
"title": "Submit orchestration decision",
"readOnlyHint": False,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
}
@@ -0,0 +1,204 @@
from __future__ import annotations
import json
from dataclasses import dataclass
import urllib.error
import urllib.request
from typing import Any
from .errors import TriggerError
from .models import DECISION_SCHEMA, Decision, Event
@dataclass(frozen=True)
class WorkspaceTriggerResult:
http_status: int
remote_id: None = None
WORKSPACE_HTTP_CATEGORIES = {
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "NOT_RUNNABLE",
}
class WorkspaceAgentClient:
"""Asynchronous Workspace Agent trigger.
The official endpoint returns only 202 and no run ID or retrievable output.
Completion therefore arrives through the gateway callback endpoint.
"""
def __init__(self, trigger_id: str, token: str, timeout_seconds: int = 30):
self.trigger_id = trigger_id
self.token = token
self.timeout_seconds = timeout_seconds
def trigger(self, event: Event) -> WorkspaceTriggerResult:
url = f"https://api.chatgpt.com/v1/workspace_agents/{self.trigger_id}/trigger"
prompt = (
"Process this AIOS orchestration event under the committed contract. "
"When complete, call submit_orchestration_decision with strict JSON. "
"Do not fabricate tests, Git state, secrets, evidence, or deployment facts.\n\n"
+ event.canonical_json()
)
body = json.dumps(
{
"conversation_key": event.project_key,
"input": prompt,
}
).encode()
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Idempotency-Key": event.event_id,
"User-Agent": "aios-orchestrator-gateway/0.2",
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
if response.status != 202:
raise TriggerError(
"workspace_agent_unexpected_http_status",
http_status=response.status,
)
return WorkspaceTriggerResult(http_status=202)
except urllib.error.HTTPError as exc:
category = WORKSPACE_HTTP_CATEGORIES.get(exc.code)
raise TriggerError(
"workspace_agent_http_error",
http_status=exc.code,
category=category,
) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise TriggerError(
"workspace_agent_network_error",
category="NETWORK_ERROR",
) from exc
class ResponsesClient:
"""Background Responses API client with polling and strict output validation."""
def __init__(self, api_key: str, model: str, timeout_seconds: int = 60):
self.api_key = api_key
self.model = model
self.timeout_seconds = timeout_seconds
def start(self, event: Event) -> str:
body = {
"model": self.model,
"background": True,
"instructions": (
"You are the AIOS central orchestration brain. Apply the exact "
"contract_commit in the event. Return only a strict decision. "
"Never fabricate evidence, secrets, Git facts, tests, approvals, "
"or deployment state. Keep external blockers fail-closed."
),
"input": event.canonical_json(),
"text": {
"format": {
"type": "json_schema",
"name": "aios_orchestrator_decision",
"strict": True,
"schema": DECISION_SCHEMA,
}
},
"metadata": {
"event_id": event.event_id,
"project_key": event.project_key,
"contract_commit": event.contract_commit,
},
}
payload = self._request(
"POST",
"https://api.openai.com/v1/responses",
body,
idempotency_key=event.event_id,
)
response_id = payload.get("id")
if not isinstance(response_id, str) or not response_id.startswith("resp_"):
raise TriggerError("responses_missing_id")
return response_id
def poll(self, response_id: str) -> tuple[str, Decision | None]:
payload = self._request(
"GET",
f"https://api.openai.com/v1/responses/{response_id}",
None,
)
status = payload.get("status")
if status in {"queued", "in_progress"}:
return str(status), None
if status == "completed":
text = _extract_output_text(payload)
try:
return "completed", Decision.from_dict(json.loads(text))
except json.JSONDecodeError as exc:
raise TriggerError("responses_invalid_json") from exc
if status in {"failed", "cancelled", "incomplete"}:
raise TriggerError(f"responses_{status}")
raise TriggerError("responses_unknown_status")
def _request(
self,
method: str,
url: str,
body: dict[str, Any] | None,
*,
idempotency_key: str | None = None,
) -> dict[str, Any]:
encoded = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"User-Agent": "aios-orchestrator-gateway/0.2",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(
url,
data=encoded,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
raw = response.read(4_194_304)
if response.status not in {200, 201}:
raise TriggerError(f"responses_http_{response.status}")
except urllib.error.HTTPError as exc:
raise TriggerError(f"responses_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise TriggerError("responses_request_failed") from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise TriggerError("responses_invalid_payload") from exc
if not isinstance(payload, dict):
raise TriggerError("responses_payload_not_object")
return payload
def _extract_output_text(payload: dict[str, Any]) -> str:
output = payload.get("output")
if not isinstance(output, list):
raise TriggerError("responses_missing_output")
for item in output:
if not isinstance(item, dict):
continue
content = item.get("content")
if not isinstance(content, list):
continue
for part in content:
if isinstance(part, dict) and part.get("type") == "output_text":
text = part.get("text")
if isinstance(text, str):
return text
raise TriggerError("responses_missing_output_text")
@@ -0,0 +1,213 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import stat
from typing import Any
from .errors import ConfigurationError
from .securefs import read_secure_bytes, read_secure_json, verify_secure_file
@dataclass(frozen=True)
class Config:
host: str
port: int
project_key: str
database_path: Path
event_inbox_dir: Path
decision_inbox_dir: Path
dispatch_outbox_dir: Path
openclaw_sync_dir: Path
command_queue_dir: Path
inbound_token_file: Path
callback_token_file: Path
contract_commit: str
workspace_trigger_id: str | None
workspace_token_file: Path | None
responses_api_key_file: Path | None
responses_model: str | None
poll_interval_seconds: int
summary_interval_seconds: int
callback_timeout_seconds: int
lease_seconds: int
retry_base_seconds: int
max_retries: int
max_same_blocker_retries: int
max_hops_per_gate: int
openclaw_sync_endpoint: str | None
openclaw_sync_token_file: Path | None
git_poll_enabled: bool
git_poll_bare_repo: Path | None
git_poll_ref_prefixes: tuple[str, ...]
git_poll_interval_seconds: int
@classmethod
def from_file(cls, path: Path) -> "Config":
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", "0"))
raw = read_secure_json(path, expected_uid=owner_uid)
expected = {
"host", "port", "project_key", "database_path", "event_inbox_dir",
"decision_inbox_dir", "dispatch_outbox_dir", "openclaw_sync_dir",
"command_queue_dir", "inbound_token_file", "callback_token_file",
"contract_commit", "workspace_agent", "responses_fallback",
"polling", "loop_controls", "openclaw_sync", "git_poll",
}
if set(raw) != expected:
raise ConfigurationError("invalid_config_fields")
def path_value(key: str) -> Path:
value = raw.get(key)
if not isinstance(value, str):
raise ConfigurationError(f"invalid_{key}")
result = Path(value)
if not result.is_absolute():
raise ConfigurationError(f"path_not_absolute:{key}")
return result
def integer(container: dict[str, Any], key: str, lo: int, hi: int) -> int:
value = container.get(key)
if not isinstance(value, int) or not lo <= value <= hi:
raise ConfigurationError(f"invalid_{key}")
return value
host = raw.get("host")
port = raw.get("port")
project_key = raw.get("project_key")
contract = raw.get("contract_commit")
if not isinstance(host, str) or not host:
raise ConfigurationError("invalid_host")
if not isinstance(project_key, str) or not project_key:
raise ConfigurationError("invalid_project_key")
if not isinstance(port, int) or not 1 <= port <= 65535:
raise ConfigurationError("invalid_port")
if not isinstance(contract, str) or len(contract) != 40:
raise ConfigurationError("invalid_contract_commit")
workspace = raw.get("workspace_agent")
responses = raw.get("responses_fallback")
polling = raw.get("polling")
loop = raw.get("loop_controls")
sync = raw.get("openclaw_sync")
git_poll = raw.get("git_poll")
if not all(isinstance(x, dict) for x in (workspace, responses, polling, loop, sync, git_poll)):
raise ConfigurationError("invalid_nested_config")
trigger_id = workspace.get("trigger_id")
workspace_token = workspace.get("access_token_file")
if trigger_id is not None:
if not isinstance(trigger_id, str) or not trigger_id.startswith("agtch_"):
raise ConfigurationError("invalid_workspace_trigger_id")
if not isinstance(workspace_token, str) or not Path(workspace_token).is_absolute():
raise ConfigurationError("invalid_workspace_token_file")
workspace_token_file = Path(workspace_token)
else:
workspace_token_file = None
responses_key = responses.get("api_key_file")
responses_model = responses.get("model")
if responses_key is not None:
if not isinstance(responses_key, str) or not Path(responses_key).is_absolute():
raise ConfigurationError("invalid_responses_key_file")
if not isinstance(responses_model, str) or not responses_model:
raise ConfigurationError("invalid_responses_model")
responses_key_file = Path(responses_key)
else:
responses_key_file = None
responses_model = None
if trigger_id is None and responses_key_file is None:
raise ConfigurationError("no_central_model_channel")
endpoint = sync.get("endpoint")
token_file = sync.get("token_file")
if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):
raise ConfigurationError("invalid_openclaw_sync_endpoint")
if endpoint is not None:
if not isinstance(token_file, str) or not Path(token_file).is_absolute():
raise ConfigurationError("invalid_openclaw_sync_token_file")
sync_token_file = Path(token_file)
else:
sync_token_file = None
git_enabled = git_poll.get("enabled")
if not isinstance(git_enabled, bool):
raise ConfigurationError("invalid_git_poll_enabled")
git_bare_repo_raw = git_poll.get("bare_repo")
git_prefixes_raw = git_poll.get("ref_prefixes")
if git_enabled:
if not isinstance(git_bare_repo_raw, str) or not Path(git_bare_repo_raw).is_absolute():
raise ConfigurationError("invalid_git_poll_bare_repo")
if (
not isinstance(git_prefixes_raw, list)
or not git_prefixes_raw
or any(not isinstance(item, str) or not item.startswith("refs/") for item in git_prefixes_raw)
):
raise ConfigurationError("invalid_git_poll_ref_prefixes")
git_bare_repo = Path(git_bare_repo_raw)
git_prefixes = tuple(git_prefixes_raw)
else:
git_bare_repo = None
git_prefixes = ()
return cls(
host=host,
port=port,
project_key=project_key,
database_path=path_value("database_path"),
event_inbox_dir=path_value("event_inbox_dir"),
decision_inbox_dir=path_value("decision_inbox_dir"),
dispatch_outbox_dir=path_value("dispatch_outbox_dir"),
openclaw_sync_dir=path_value("openclaw_sync_dir"),
command_queue_dir=path_value("command_queue_dir"),
inbound_token_file=path_value("inbound_token_file"),
callback_token_file=path_value("callback_token_file"),
contract_commit=contract,
workspace_trigger_id=trigger_id,
workspace_token_file=workspace_token_file,
responses_api_key_file=responses_key_file,
responses_model=responses_model,
poll_interval_seconds=integer(polling, "interval_seconds", 1, 300),
summary_interval_seconds=integer(polling, "summary_interval_seconds", 10, 86400),
callback_timeout_seconds=integer(loop, "callback_timeout_seconds", 30, 86400),
lease_seconds=integer(loop, "lease_seconds", 10, 3600),
retry_base_seconds=integer(loop, "retry_base_seconds", 1, 3600),
max_retries=integer(loop, "max_retries", 0, 20),
max_same_blocker_retries=integer(loop, "max_same_blocker_retries", 0, 20),
max_hops_per_gate=integer(loop, "max_hops_per_gate", 1, 100),
openclaw_sync_endpoint=endpoint,
openclaw_sync_token_file=sync_token_file,
git_poll_enabled=git_enabled,
git_poll_bare_repo=git_bare_repo,
git_poll_ref_prefixes=git_prefixes,
git_poll_interval_seconds=integer(git_poll, "interval_seconds", 5, 3600),
)
def _secret(self, path: Path) -> str:
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", "0"))
info = verify_secure_file(path, expected_uid=owner_uid)
if stat.S_IMODE(info.st_mode) & ~0o640:
raise ConfigurationError(f"permissive_secret_mode:{path}")
data = read_secure_bytes(path, expected_uid=owner_uid, max_bytes=64_000)
if not data or b"\n" in data or b"\r" in data:
raise ConfigurationError(f"invalid_secret_format:{path}")
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
raise ConfigurationError(f"secret_not_utf8:{path}") from exc
def inbound_token(self) -> str:
return self._secret(self.inbound_token_file)
def callback_token(self) -> str:
return self._secret(self.callback_token_file)
def workspace_token(self) -> str | None:
return self._secret(self.workspace_token_file) if self.workspace_token_file else None
def responses_api_key(self) -> str | None:
return self._secret(self.responses_api_key_file) if self.responses_api_key_file else None
def openclaw_sync_token(self) -> str | None:
return self._secret(self.openclaw_sync_token_file) if self.openclaw_sync_token_file else None
@@ -0,0 +1,122 @@
from __future__ import annotations
import json
from pathlib import Path
import re
import os
import stat
from typing import Any
from .errors import DispatchError
from .securefs import (
atomic_write_bytes,
atomic_write_bytes_shared,
ensure_secure_directory,
ensure_shared_directory,
read_secure_bytes,
)
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class DispatchSink:
"""Reliable file-based dispatch adapter for existing OpenClaw/Codex watchers."""
def __init__(self, outbox_dir: Path, command_queue_dir: Path, contract_commit: str):
self.outbox_dir = Path(outbox_dir)
self.command_queue_dir = Path(command_queue_dir)
self.contract_commit = contract_commit
ensure_secure_directory(self.outbox_dir)
ensure_shared_directory(self.command_queue_dir)
def dispatch(self, record: dict[str, Any]) -> tuple[Path, Path]:
work_unit_id = record.get("work_unit_id")
event_id = record.get("event_id")
agent = record.get("assigned_agent")
payload_json = record.get("payload_json")
if (
not isinstance(work_unit_id, str)
or not SAFE_ID.fullmatch(work_unit_id)
or not isinstance(event_id, str)
or not SAFE_ID.fullmatch(event_id)
or agent not in {"openclaw", "codex"}
or not isinstance(payload_json, str)
):
raise DispatchError("invalid_dispatch_record")
try:
work_unit = json.loads(payload_json)
except json.JSONDecodeError as exc:
raise DispatchError("invalid_dispatch_payload") from exc
project_key = record.get("project_key")
state_version = record.get("state_version")
envelope = {
"schema_version": 1,
"contract_commit": self.contract_commit,
"event_id": event_id,
"assigned_agent": agent,
"work_unit": work_unit,
"callback": {
"event_endpoint": "/v1/orchestrator/events",
"start_event_type": "work_unit_started",
"end_event_type": "work_unit_completed",
},
}
outbox_path = self.outbox_dir / "dispatched" / f"{work_unit_id}.json"
if agent == "codex":
if (
not isinstance(project_key, str)
or not SAFE_ID.fullmatch(project_key)
or not isinstance(state_version, int)
or state_version < 0
):
raise DispatchError("invalid_dispatch_context")
required_checks = work_unit.get("required_checks", [])
execution_kind = (
"acceptance_probe"
if isinstance(required_checks, list) and "acceptance_probe" in required_checks
else "codex"
)
queue_value = {
"schema_version": 1,
"command_id": work_unit_id,
"project_key": project_key,
"work_unit_id": work_unit_id,
"contract_commit": self.contract_commit,
"state_version": state_version,
"execution": {
"kind": execution_kind,
"input": work_unit.get("instruction", ""),
},
}
# The Codex watcher polls the configured active directory itself.
queue_path = self.command_queue_dir / f"{work_unit_id}.json"
else:
queue_value = envelope
queue_path = self.command_queue_dir / agent / "pending" / f"{work_unit_id}.json"
ensure_secure_directory(outbox_path.parent)
ensure_shared_directory(queue_path.parent)
# Idempotent immutable payload: existing identical file is accepted.
self._write_idempotent_json(outbox_path, envelope, shared=False)
self._write_idempotent_json(queue_path, queue_value, shared=True)
return outbox_path, queue_path
@staticmethod
def _write_idempotent_json(path: Path, value: dict[str, Any], *, shared: bool) -> None:
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
try:
st = os.lstat(path)
except FileNotFoundError:
if shared:
atomic_write_bytes_shared(path, payload, mode=0o640)
else:
atomic_write_bytes(path, payload, mode=0o600)
return
if not stat.S_ISREG(st.st_mode):
raise DispatchError(f"dispatch_target_not_regular:{path.name}")
existing = read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576)
if existing != payload:
raise DispatchError(f"dispatch_idempotency_conflict:{path.name}")
@@ -0,0 +1,37 @@
class OrchestratorError(Exception):
"""Base orchestration error."""
class ConfigurationError(OrchestratorError):
"""Unsafe or invalid configuration."""
class ValidationError(OrchestratorError):
"""Invalid event or decision."""
class AuthenticationError(OrchestratorError):
"""Authentication failed."""
class ConflictError(OrchestratorError):
"""Idempotency, lease, or optimistic concurrency conflict."""
class TriggerError(OrchestratorError):
"""Central model trigger failed."""
def __init__(
self,
message: str,
*,
http_status: int | None = None,
category: str | None = None,
) -> None:
super().__init__(message)
self.http_status = http_status
self.category = category
class DispatchError(OrchestratorError):
"""Dispatch or synchronization output failed."""
@@ -0,0 +1,346 @@
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
import json
import re
from typing import Any
from .errors import ValidationError
OID_RE = re.compile(r"^[0-9a-f]{40}$")
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
AGENTS = {"openclaw", "codex", "gitea", "system", "workspace-agent"}
EVENT_STATUSES = {"READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"}
VERDICTS = {"DISPATCH", "BLOCKED", "COMPLETE", "NOOP"}
EXECUTORS = {"openclaw", "codex"}
def req_str(value: dict[str, Any], key: str, pattern: re.Pattern[str] | None = None) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise ValidationError(f"invalid_{key}")
if pattern is not None and not pattern.fullmatch(item):
raise ValidationError(f"invalid_{key}")
return item
def opt_oid(item: Any, key: str) -> str | None:
if item is None:
return None
if not isinstance(item, str) or not OID_RE.fullmatch(item):
raise ValidationError(f"invalid_{key}")
return item
def string_list(value: dict[str, Any], key: str, limit: int) -> tuple[str, ...]:
raw = value.get(key)
if not isinstance(raw, list) or len(raw) > limit:
raise ValidationError(f"invalid_{key}")
result: list[str] = []
for item in raw:
if not isinstance(item, str) or not item or len(item) > 1024:
raise ValidationError(f"invalid_{key}")
result.append(item)
return tuple(result)
@dataclass(frozen=True)
class Event:
schema_version: int
project_key: str
contract_commit: str
event_id: str
source_agent: str
event_type: str
work_unit_id: str
status: str
state_version: int
implementation_commit: str | None = None
result_commit: str | None = None
blocker: str | None = None
evidence_refs: tuple[str, ...] = ()
payload: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "Event":
allowed = {
"schema_version", "project_key", "contract_commit", "event_id",
"source_agent", "event_type", "work_unit_id", "status",
"state_version", "implementation_commit", "result_commit",
"blocker", "evidence_refs", "payload",
}
if not isinstance(value, dict) or set(value) - allowed:
raise ValidationError("invalid_event_fields")
if value.get("schema_version") != 1:
raise ValidationError("unsupported_event_schema")
source = req_str(value, "source_agent")
status = req_str(value, "status")
if source not in AGENTS:
raise ValidationError("invalid_source_agent")
if status not in EVENT_STATUSES:
raise ValidationError("invalid_event_status")
version = value.get("state_version")
if not isinstance(version, int) or version < 0:
raise ValidationError("invalid_state_version")
blocker = value.get("blocker")
if blocker is not None and (not isinstance(blocker, str) or len(blocker) > 512):
raise ValidationError("invalid_blocker")
payload = value.get("payload", {})
if not isinstance(payload, dict):
raise ValidationError("invalid_payload")
if len(json.dumps(payload, sort_keys=True).encode()) > 262_144:
raise ValidationError("payload_too_large")
return cls(
schema_version=1,
project_key=req_str(value, "project_key", ID_RE),
contract_commit=req_str(value, "contract_commit", OID_RE),
event_id=req_str(value, "event_id", ID_RE),
source_agent=source,
event_type=req_str(value, "event_type", ID_RE),
work_unit_id=req_str(value, "work_unit_id", ID_RE),
status=status,
state_version=version,
implementation_commit=opt_oid(value.get("implementation_commit"), "implementation_commit"),
result_commit=opt_oid(value.get("result_commit"), "result_commit"),
blocker=blocker,
evidence_refs=string_list(value, "evidence_refs", 64),
payload=payload,
)
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": 1,
"project_key": self.project_key,
"contract_commit": self.contract_commit,
"event_id": self.event_id,
"source_agent": self.source_agent,
"event_type": self.event_type,
"work_unit_id": self.work_unit_id,
"status": self.status,
"state_version": self.state_version,
"implementation_commit": self.implementation_commit,
"result_commit": self.result_commit,
"blocker": self.blocker,
"evidence_refs": list(self.evidence_refs),
"payload": self.payload,
}
def canonical_json(self) -> str:
return json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True)
def digest(self) -> str:
return hashlib.sha256(self.canonical_json().encode()).hexdigest()
@dataclass(frozen=True)
class WorkUnit:
id: str
track_id: str
gate_id: str
primary_agent: str
fallback_agent: str
clean_base_commit: str
allowed_paths: tuple[str, ...]
required_checks: tuple[str, ...]
forbidden_operations: tuple[str, ...]
instruction: str
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "WorkUnit":
expected = {
"id", "track_id", "gate_id", "primary_agent", "fallback_agent",
"clean_base_commit", "allowed_paths", "required_checks",
"forbidden_operations", "instruction",
}
if not isinstance(value, dict) or set(value) != expected:
raise ValidationError("invalid_work_unit_fields")
primary = req_str(value, "primary_agent")
fallback = req_str(value, "fallback_agent")
if primary not in EXECUTORS or fallback not in EXECUTORS or primary == fallback:
raise ValidationError("invalid_agent_assignment")
instruction = req_str(value, "instruction")
if len(instruction.encode()) > 65_536:
raise ValidationError("instruction_too_large")
return cls(
id=req_str(value, "id", ID_RE),
track_id=req_str(value, "track_id", ID_RE),
gate_id=req_str(value, "gate_id", ID_RE),
primary_agent=primary,
fallback_agent=fallback,
clean_base_commit=req_str(value, "clean_base_commit", OID_RE),
allowed_paths=string_list(value, "allowed_paths", 128),
required_checks=string_list(value, "required_checks", 128),
forbidden_operations=string_list(value, "forbidden_operations", 64),
instruction=instruction,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"track_id": self.track_id,
"gate_id": self.gate_id,
"primary_agent": self.primary_agent,
"fallback_agent": self.fallback_agent,
"clean_base_commit": self.clean_base_commit,
"allowed_paths": list(self.allowed_paths),
"required_checks": list(self.required_checks),
"forbidden_operations": list(self.forbidden_operations),
"instruction": self.instruction,
}
@dataclass(frozen=True)
class Decision:
schema_version: int
project_key: str
contract_commit: str
event_id: str
expected_state_version: int
verdict: str
state_updates: dict[str, str]
next_work_units: tuple[WorkUnit, ...]
requires_human_input: bool
stop_reason: str | None
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "Decision":
expected = {
"schema_version", "project_key", "contract_commit", "event_id",
"expected_state_version", "verdict", "state_updates",
"next_work_units", "requires_human_input", "stop_reason",
}
if not isinstance(value, dict) or set(value) != expected:
raise ValidationError("invalid_decision_fields")
if value.get("schema_version") != 1:
raise ValidationError("unsupported_decision_schema")
version = value.get("expected_state_version")
if not isinstance(version, int) or version < 0:
raise ValidationError("invalid_expected_state_version")
verdict = req_str(value, "verdict")
if verdict not in VERDICTS:
raise ValidationError("invalid_verdict")
updates = value.get("state_updates")
normalized: dict[str, str] = {}
if isinstance(updates, dict):
update_items = [{"key": key, "value": item} for key, item in updates.items()]
elif isinstance(updates, list):
update_items = updates
else:
raise ValidationError("invalid_state_updates")
if len(update_items) > 128:
raise ValidationError("invalid_state_updates")
for update in update_items:
if not isinstance(update, dict) or set(update) != {"key", "value"}:
raise ValidationError("invalid_state_update")
key = update.get("key")
item = update.get("value")
if not isinstance(key, str) or not ID_RE.fullmatch(key):
raise ValidationError("invalid_state_update_key")
if not isinstance(item, str) or not item or len(item) > 256:
raise ValidationError("invalid_state_update_value")
if key in normalized:
raise ValidationError("duplicate_state_update_key")
normalized[key] = item
raw_units = value.get("next_work_units")
if not isinstance(raw_units, list) or len(raw_units) > 32:
raise ValidationError("invalid_next_work_units")
units = tuple(WorkUnit.from_dict(item) for item in raw_units)
human = value.get("requires_human_input")
if not isinstance(human, bool):
raise ValidationError("invalid_requires_human_input")
stop = value.get("stop_reason")
if stop is not None and (not isinstance(stop, str) or len(stop) > 512):
raise ValidationError("invalid_stop_reason")
if verdict == "DISPATCH" and not units:
raise ValidationError("dispatch_without_work_units")
if verdict != "DISPATCH" and units:
raise ValidationError("work_units_without_dispatch")
return cls(
schema_version=1,
project_key=req_str(value, "project_key", ID_RE),
contract_commit=req_str(value, "contract_commit", OID_RE),
event_id=req_str(value, "event_id", ID_RE),
expected_state_version=version,
verdict=verdict,
state_updates=normalized,
next_work_units=units,
requires_human_input=human,
stop_reason=stop,
)
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": 1,
"project_key": self.project_key,
"contract_commit": self.contract_commit,
"event_id": self.event_id,
"expected_state_version": self.expected_state_version,
"verdict": self.verdict,
"state_updates": [
{"key": key, "value": value}
for key, value in sorted(self.state_updates.items())
],
"next_work_units": [u.to_dict() for u in self.next_work_units],
"requires_human_input": self.requires_human_input,
"stop_reason": self.stop_reason,
}
DECISION_SCHEMA: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"required": [
"schema_version", "project_key", "contract_commit", "event_id",
"expected_state_version", "verdict", "state_updates",
"next_work_units", "requires_human_input", "stop_reason",
],
"properties": {
"schema_version": {"type": "integer", "const": 1},
"project_key": {"type": "string"},
"contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"event_id": {"type": "string"},
"expected_state_version": {"type": "integer", "minimum": 0},
"verdict": {"type": "string", "enum": sorted(VERDICTS)},
"state_updates": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["key", "value"],
"properties": {
"key": {"type": "string"},
"value": {"type": "string"}
}
}
},
"next_work_units": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": False,
"required": [
"id", "track_id", "gate_id", "primary_agent",
"fallback_agent", "clean_base_commit", "allowed_paths",
"required_checks", "forbidden_operations", "instruction",
],
"properties": {
"id": {"type": "string"},
"track_id": {"type": "string"},
"gate_id": {"type": "string"},
"primary_agent": {"type": "string", "enum": ["openclaw", "codex"]},
"fallback_agent": {"type": "string", "enum": ["openclaw", "codex"]},
"clean_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"allowed_paths": {"type": "array", "items": {"type": "string"}},
"required_checks": {"type": "array", "items": {"type": "string"}},
"forbidden_operations": {"type": "array", "items": {"type": "string"}},
"instruction": {"type": "string"},
},
},
},
"requires_human_input": {"type": "boolean"},
"stop_reason": {"type": ["string", "null"]},
},
}
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import socket
from .store import Store
@dataclass(frozen=True)
class RecoverySettings:
database_path: Path
contract_commit: str
lease_seconds: int
max_retries: int
retry_after: int
class PendingDecisionRecovery:
def __init__(self, settings: RecoverySettings):
self.settings = settings
self.store = Store(settings.database_path)
self.owner = f"recovery-{socket.gethostname()}"
def plan(self) -> dict[str, int]:
return self.store.pending_decision_plan(
self.settings.contract_commit, self.settings.max_retries
)
def apply_one(self) -> str | None:
# Recovery never calls a model or replays a decision. It only advances
# durable callback bookkeeping under a lease.
return self.store.recover_one_pending(
owner=self.owner,
contract_commit=self.settings.contract_commit,
lease_seconds=self.settings.lease_seconds,
max_retries=self.settings.max_retries,
retry_after=self.settings.retry_after,
)
@@ -0,0 +1,122 @@
from __future__ import annotations
import hmac
import json
import os
from pathlib import Path
import secrets
import stat
import time
from typing import Any, Callable
from .callback_transport import CallbackTransport
from .errors import AuthenticationError, ValidationError
from .models import Decision
from .models import ID_RE, OID_RE
from .securefs import ensure_secure_directory
AUDIT_FIELDS = (
"callback_request_id", "event_id", "contract_commit", "received_at",
"schema_valid", "auth_valid", "forward_status", "state_version_after",
)
class CallbackAudit:
def __init__(self, path: Path):
self.path = path
def write(self, record: dict[str, Any]) -> None:
if set(record) != set(AUDIT_FIELDS):
raise ValueError("invalid_callback_audit_fields")
ensure_secure_directory(self.path.parent)
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(self.path, flags, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077:
raise ValueError("unsafe_callback_audit_file")
data = (json.dumps(record, separators=(",", ":"), sort_keys=True) + "\n").encode()
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
class MinimalDecisionTool:
"""Transport-neutral single-tool boundary; no generic GitOps operations."""
def __init__(
self,
transport: CallbackTransport,
*,
contract_commit: str,
access_token: str,
forward: Callable[[dict[str, Any]], dict[str, Any]],
audit: CallbackAudit,
):
self.transport = transport
self.contract_commit = contract_commit
self.access_token = access_token
self.forward = forward
self.audit = audit
def tools(self) -> list[dict[str, Any]]:
return [self.transport.mcp_tool()] if self.transport.mode == "secure_mcp_tunnel" else []
def authenticate(self, authorization: str) -> None:
if not hmac.compare_digest(authorization, f"Bearer {self.access_token}"):
raise AuthenticationError("invalid_bearer")
def submit(self, payload: dict[str, Any], authorization: str) -> dict[str, Any]:
request_id = secrets.token_hex(16)
received_at = int(time.time())
schema_valid = False
auth_valid = False
raw_event_id = payload.get("event_id") if isinstance(payload, dict) else None
raw_contract = payload.get("contract_commit") if isinstance(payload, dict) else None
event_id = raw_event_id if isinstance(raw_event_id, str) and ID_RE.fullmatch(raw_event_id) else None
contract = raw_contract if isinstance(raw_contract, str) and OID_RE.fullmatch(raw_contract) else None
forward_status = "REJECTED"
state_version = None
try:
self.authenticate(authorization)
auth_valid = True
decision = Decision.from_dict(payload)
schema_valid = True
if decision.contract_commit != self.contract_commit:
raise ValidationError("contract_version_mismatch")
result = self.forward(decision.to_dict())
if result.get("accepted") is not True or not isinstance(result.get("state_version"), int):
raise ValidationError("invalid_forward_response")
forward_status = "202"
state_version = result["state_version"]
return result
finally:
self.audit.write({
"callback_request_id": request_id,
"event_id": event_id,
"contract_commit": contract,
"received_at": received_at,
"schema_valid": schema_valid,
"auth_valid": auth_valid,
"forward_status": forward_status,
"state_version_after": state_version,
})
def handle_mcp(self, request: dict[str, Any], authorization: str) -> dict[str, Any]:
self.authenticate(authorization)
method = request.get("method")
request_id = request.get("id")
if method == "tools/list":
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": self.tools()}}
if method != "tools/call":
raise ValidationError("unsupported_mcp_method")
params = request.get("params")
if not isinstance(params, dict) or params.get("name") != "submit_orchestration_decision":
raise ValidationError("unsupported_mcp_tool")
arguments = params.get("arguments")
if not isinstance(arguments, dict):
raise ValidationError("invalid_mcp_arguments")
result = self.submit(arguments, authorization)
return {"jsonrpc": "2.0", "id": request_id, "result": {"structuredContent": result}}
@@ -0,0 +1,144 @@
from __future__ import annotations
from dataclasses import dataclass
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from .action_bridge import resolve_secret_ref, submit_orchestration_decision
from .callback_transport import CallbackTransport
from .errors import AuthenticationError, ConfigurationError, OrchestratorError
from .remote_tool import CallbackAudit, MinimalDecisionTool
from .securefs import read_secure_json
@dataclass(frozen=True)
class RemoteToolServerConfig:
listen_host: str
listen_port: int
contract_commit: str
transport: CallbackTransport
access_secret_ref: str
callback_secret_ref: str
secret_ref_resolver: Path
audit_path: Path
manifest_path: Path
@classmethod
def from_file(cls, path: Path) -> "RemoteToolServerConfig":
value = read_secure_json(path, expected_uid=None)
expected = {
"listen_host", "listen_port", "contract_commit", "callback_transport",
"access_secret_ref", "callback_secret_ref", "secret_ref_resolver",
"audit_path", "manifest_path",
}
if set(value) != expected:
raise ConfigurationError("invalid_remote_tool_config_fields")
if value["listen_host"] not in {"127.0.0.1", "::1"}:
raise ConfigurationError("remote_tool_must_listen_loopback")
if not isinstance(value["listen_port"], int) or not 1 <= value["listen_port"] <= 65535:
raise ConfigurationError("invalid_remote_tool_port")
contract = value["contract_commit"]
if not isinstance(contract, str) or len(contract) != 40:
raise ConfigurationError("invalid_contract_commit")
paths = [Path(value[name]) for name in ("secret_ref_resolver", "audit_path", "manifest_path")]
if any(not path_value.is_absolute() for path_value in paths):
raise ConfigurationError("remote_tool_path_not_absolute")
for name in ("access_secret_ref", "callback_secret_ref"):
if not isinstance(value[name], str) or not value[name].startswith("secret://"):
raise ConfigurationError("remote_tool_secret_ref_required")
return cls(
value["listen_host"], value["listen_port"], contract,
CallbackTransport.from_dict(value["callback_transport"]),
value["access_secret_ref"], value["callback_secret_ref"], paths[0],
paths[1], paths[2],
)
class Handler(BaseHTTPRequestHandler):
app: MinimalDecisionTool
config: RemoteToolServerConfig
def log_message(self, _format: str, *_args: object) -> None:
return
def reply(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def body(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 1_048_576:
raise ValueError("invalid_body_size")
value = json.loads(self.rfile.read(length))
if not isinstance(value, dict):
raise ValueError("invalid_json_root")
return value
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/healthz":
self.reply(200, {"status": "active", "mode": self.config.transport.mode})
return
if path == "/orchestrator-tool-manifest":
value = json.loads(self.config.manifest_path.read_text(encoding="utf-8"))
self.reply(200, value)
return
self.reply(404, {"error": "not_found"})
def do_POST(self) -> None:
path = urlparse(self.path).path.rstrip("/")
try:
if self.config.transport.mode == "remote_https_action" and path == "/v1/orchestrator/decisions":
result = self.app.submit(self.body(), self.headers.get("Authorization", ""))
elif self.config.transport.mode == "secure_mcp_tunnel" and path == "/mcp":
result = self.app.handle_mcp(self.body(), self.headers.get("Authorization", ""))
else:
self.reply(404, {"error": "not_found"})
return
self.reply(202, result)
except AuthenticationError:
self.reply(401, {"error": "UNAUTHORIZED"})
except OrchestratorError as exc:
self.reply(422, {"error": type(exc).__name__})
except Exception:
self.reply(500, {"error": "INTERNAL_ERROR"})
def serve(config_path: Path) -> None:
config = RemoteToolServerConfig.from_file(config_path)
access_token = resolve_secret_ref(config.access_secret_ref, config.secret_ref_resolver)
def forward(payload: dict[str, Any]) -> dict[str, Any]:
return submit_orchestration_decision(
payload, contract_commit=config.contract_commit,
secret_ref=config.callback_secret_ref,
resolver=config.secret_ref_resolver,
)
Handler.config = config
Handler.app = MinimalDecisionTool(
config.transport, contract_commit=config.contract_commit,
access_token=access_token, forward=forward, audit=CallbackAudit(config.audit_path),
)
ThreadingHTTPServer((config.listen_host, config.listen_port), Handler).serve_forever()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("config", type=Path)
args = parser.parse_args()
serve(args.config)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,228 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
from typing import Any
from .errors import ConfigurationError
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
def reject_symlink_chain(path: Path) -> None:
"""Reject symlinks in every existing component of an absolute path."""
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
current = Path(path.anchor)
for part in path.parts[1:]:
current = current / part
try:
st = os.lstat(current)
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
raise ConfigurationError(f"symlink_rejected:{current}")
def verify_secure_file(
path: Path,
*,
expected_uid: int | None,
allowed_write_bits: int = stat.S_IWUSR,
require_executable: bool = False,
) -> os.stat_result:
path = Path(path)
reject_symlink_chain(path)
before = os.lstat(path)
if not stat.S_ISREG(before.st_mode):
raise ConfigurationError(f"not_regular_file:{path}")
if expected_uid is not None and before.st_uid != expected_uid:
raise ConfigurationError(f"unexpected_owner:{path}")
write_bits = before.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
if write_bits & ~allowed_write_bits:
raise ConfigurationError(f"permissive_mode:{path}")
if require_executable and not (before.st_mode & stat.S_IXUSR):
raise ConfigurationError(f"not_executable:{path}")
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
try:
after = os.fstat(fd)
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
raise ConfigurationError(f"inode_changed:{path}")
finally:
os.close(fd)
return before
def read_secure_bytes(
path: Path,
*,
expected_uid: int | None,
max_bytes: int = 1_048_576,
) -> bytes:
path = Path(path)
before = verify_secure_file(path, expected_uid=expected_uid)
if before.st_size > max_bytes:
raise ConfigurationError(f"file_too_large:{path}")
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
try:
data = bytearray()
while True:
chunk = os.read(fd, min(65536, max_bytes + 1 - len(data)))
if not chunk:
break
data.extend(chunk)
if len(data) > max_bytes:
raise ConfigurationError(f"file_too_large:{path}")
return bytes(data)
finally:
os.close(fd)
def read_secure_text(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> str:
try:
return read_secure_bytes(path, expected_uid=expected_uid, max_bytes=max_bytes).decode("utf-8").strip()
except UnicodeDecodeError as exc:
raise ConfigurationError(f"not_utf8:{path}") from exc
def read_secure_json(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> dict[str, Any]:
try:
value = json.loads(read_secure_text(path, expected_uid=expected_uid, max_bytes=max_bytes))
except json.JSONDecodeError as exc:
raise ConfigurationError(f"invalid_json:{path}") from exc
if not isinstance(value, dict):
raise ConfigurationError(f"json_root_not_object:{path}")
return value
def ensure_secure_directory(path: Path, *, mode: int = 0o700) -> None:
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
reject_symlink_chain(path.parent)
path.mkdir(parents=True, exist_ok=True, mode=mode)
reject_symlink_chain(path)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode):
raise ConfigurationError(f"not_directory:{path}")
if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ConfigurationError(f"permissive_directory:{path}")
def atomic_write_bytes(path: Path, payload: bytes, *, mode: int = 0o600) -> None:
"""Write through a verified directory fd and atomically replace the target."""
path = Path(path)
ensure_secure_directory(path.parent)
parent_before = os.lstat(path.parent)
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
try:
parent_after = os.fstat(parent_fd)
if (parent_before.st_dev, parent_before.st_ino) != (
parent_after.st_dev,
parent_after.st_ino,
):
raise ConfigurationError(f"directory_changed:{path.parent}")
fd = os.open(
temp_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
mode,
dir_fd=parent_fd,
)
try:
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
os.replace(
temp_name,
path.name,
src_dir_fd=parent_fd,
dst_dir_fd=parent_fd,
)
os.fsync(parent_fd)
finally:
try:
os.unlink(temp_name, dir_fd=parent_fd)
except FileNotFoundError:
pass
os.close(parent_fd)
def atomic_write_json(path: Path, value: Any, *, mode: int = 0o600) -> None:
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
atomic_write_bytes(path, payload, mode=mode)
def ensure_shared_directory(path: Path, *, group_writable: bool = False) -> None:
"""Verify/create a setgid queue directory shared with a consumer group."""
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
reject_symlink_chain(path.parent)
mode = 0o2770 if group_writable else 0o2750
try:
os.lstat(path)
except FileNotFoundError:
path.mkdir(parents=True, mode=mode)
os.chmod(path, mode)
reject_symlink_chain(path)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode):
raise ConfigurationError(f"not_directory:{path}")
observed = stat.S_IMODE(st.st_mode)
required = stat.S_ISGID | stat.S_IRGRP | stat.S_IXGRP
if group_writable:
required |= stat.S_IWGRP
if observed & 0o007 or observed & required != required:
raise ConfigurationError(f"invalid_shared_directory_mode:{path}")
def atomic_write_bytes_shared(
path: Path,
payload: bytes,
*,
mode: int = 0o640,
group_writable_directory: bool = False,
) -> None:
"""Atomic write into a setgid/group-readable shared queue directory."""
path = Path(path)
ensure_shared_directory(path.parent, group_writable=group_writable_directory)
parent_before = os.lstat(path.parent)
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
try:
parent_after = os.fstat(parent_fd)
if (parent_before.st_dev, parent_before.st_ino) != (
parent_after.st_dev,
parent_after.st_ino,
):
raise ConfigurationError(f"directory_changed:{path.parent}")
fd = os.open(
temp_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
mode,
dir_fd=parent_fd,
)
os.fchmod(fd, mode)
try:
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
os.replace(temp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
os.fsync(parent_fd)
finally:
try:
os.unlink(temp_name, dir_fd=parent_fd)
except FileNotFoundError:
pass
os.close(parent_fd)
@@ -0,0 +1,702 @@
from __future__ import annotations
import hashlib
import hmac
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import logging
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
from typing import Any
from urllib.parse import urlparse
from .clients import ResponsesClient, WorkspaceAgentClient
from .config import Config
from .dispatch import DispatchSink
from .errors import (
AuthenticationError,
ConflictError,
DispatchError,
OrchestratorError,
TriggerError,
ValidationError,
)
from .models import Decision, Event, ID_RE
from .securefs import ensure_secure_directory, ensure_shared_directory, read_secure_json
from .store import Store
from .sync import OpenClawSynchronizer, lifecycle_record
LOG = logging.getLogger("aios-orchestrator-gateway")
class Runtime:
def __init__(self, config: Config):
self.config = config
self.store = Store(config.database_path)
self.dispatch = DispatchSink(
config.dispatch_outbox_dir,
config.command_queue_dir,
config.contract_commit,
)
self.workspace = None
workspace_token = config.workspace_token()
if config.workspace_trigger_id and workspace_token:
self.workspace = WorkspaceAgentClient(
config.workspace_trigger_id,
workspace_token,
)
self.responses = None
responses_key = config.responses_api_key()
if responses_key and config.responses_model:
self.responses = ResponsesClient(
responses_key,
config.responses_model,
)
self.synchronizer = OpenClawSynchronizer(
config.openclaw_sync_dir,
endpoint=config.openclaw_sync_endpoint,
bearer_token=config.openclaw_sync_token(),
)
self.worker_id = f"{socket.gethostname()}-{os.getpid()}-{threading.get_native_id()}"
self.stop_event = threading.Event()
self.worker = threading.Thread(target=self._loop, daemon=True)
self._next_git_poll = 0.0
for directory in (
config.event_inbox_dir,
config.decision_inbox_dir,
config.dispatch_outbox_dir,
config.openclaw_sync_dir,
):
ensure_secure_directory(directory)
ensure_shared_directory(config.command_queue_dir)
def start(self) -> None:
self.worker.start()
def stop(self) -> None:
self.stop_event.set()
if self.worker.is_alive():
self.worker.join(timeout=15)
self.store.close()
def submit_event(self, payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
event = Event.from_dict(payload)
if event.contract_commit != self.config.contract_commit:
raise ConflictError("contract_version_mismatch")
created, stored = self.store.register_event(event)
if created:
self._enqueue_event_lifecycle(event)
return created, stored
def submit_decision(self, payload: dict[str, Any]) -> int:
decision = Decision.from_dict(payload)
if decision.contract_commit != self.config.contract_commit:
raise ConflictError("contract_version_mismatch")
try:
version = self.store.apply_decision(
decision,
max_hops_per_gate=self.config.max_hops_per_gate,
max_same_blocker_retries=self.config.max_same_blocker_retries,
)
except ConflictError as exc:
if str(exc) == "stale_decision_state_version":
self.store.mark_callback_state(decision.event_id, "STALE_SUPERSEDED")
raise
self._enqueue_decision_lifecycle(decision, version)
return version
def _loop(self) -> None:
next_summary = 0.0
while not self.stop_event.wait(self.config.poll_interval_seconds):
try:
self.poll_once()
if time.monotonic() >= next_summary:
self.enqueue_periodic_summaries()
next_summary = time.monotonic() + self.config.summary_interval_seconds
except Exception:
LOG.exception("poll_cycle_failed")
def poll_once(self) -> None:
"""One deterministic reliability cycle.
This is deliberately callable from a systemd timer or health supervisor
in addition to the internal loop.
"""
self.ingest_file_inboxes()
self.poll_git_refs()
self.process_new_events()
self.process_workspace_timeouts()
self.poll_background_responses()
self.dispatch_work_units()
self.synchronize_openclaw()
def ingest_file_inboxes(self) -> None:
self._ingest_directory(self.config.event_inbox_dir, kind="event")
self._ingest_directory(self.config.decision_inbox_dir, kind="decision")
def _ingest_directory(self, directory: Path, *, kind: str) -> None:
pending = directory / "pending"
processed = directory / "processed"
rejected = directory / "rejected"
ensure_shared_directory(pending, group_writable=True)
ensure_secure_directory(processed)
ensure_secure_directory(rejected)
for path in sorted(pending.glob("*.json"))[:128]:
try:
payload = read_secure_json(path, expected_uid=None)
if kind == "event":
self.submit_event(payload)
else:
self.submit_decision(payload)
target = processed / path.name
except Exception as exc:
LOG.warning("file_inbox_rejected path=%s error=%s", path, type(exc).__name__)
target = rejected / path.name
if target.exists():
# Same file name is an idempotency replay. Keep original audit copy.
path.unlink()
else:
path.replace(target)
def poll_git_refs(self) -> None:
"""Poll authoritative local bare refs so missed webhooks cannot stall the loop."""
if not self.config.git_poll_enabled or self.config.git_poll_bare_repo is None:
return
now = time.monotonic()
if now < self._next_git_poll:
return
self._next_git_poll = now + self.config.git_poll_interval_seconds
command = [
"git",
f"--git-dir={self.config.git_poll_bare_repo}",
"for-each-ref",
"--format=%(refname) %(objectname)",
*self.config.git_poll_ref_prefixes,
]
try:
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
timeout=30,
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
)
except (OSError, subprocess.SubprocessError):
LOG.exception("git_ref_poll_failed")
return
if result.returncode != 0:
LOG.warning("git_ref_poll_rc=%s", result.returncode)
return
refs: dict[str, str] = {}
for line in result.stdout.splitlines():
refname, sep, oid = line.partition(" ")
if not sep or not refname.startswith("refs/") or len(oid) != 40:
continue
if any(ch not in "0123456789abcdef" for ch in oid):
continue
refs[refname] = oid
initialized, changes = self.store.observe_refs(refs)
if not initialized or not changes:
return
payload_changes = [
{"ref": refname, "old_oid": old_oid, "new_oid": new_oid}
for refname, old_oid, new_oid in sorted(changes)
]
canonical = json.dumps(payload_changes, separators=(",", ":"), sort_keys=True)
event_id = "gitpoll-" + hashlib.sha256(canonical.encode()).hexdigest()[:40]
state = self.store.project_state(self.config.project_key)
state_version = 0 if state is None else int(state["state_version"])
event = Event.from_dict(
{
"schema_version": 1,
"project_key": self.config.project_key,
"contract_commit": self.config.contract_commit,
"event_id": event_id,
"source_agent": "gitea",
"event_type": "git_refs_changed",
"work_unit_id": "GIT-REF-POLL",
"status": "READY",
"state_version": state_version,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {"changes": payload_changes},
}
)
try:
self.submit_event(event.to_dict())
except ConflictError:
LOG.info("git_ref_event_already_registered event_id=%s", event_id)
def process_new_events(self) -> None:
rows = self.store.claim_events(
owner=self.worker_id,
statuses=("RECEIVED", "TRIGGER_FAILED"),
limit=1,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
event = Event.from_dict(json.loads(row["event_json"]))
attempts = int(row["attempts"])
try:
if self.workspace is not None:
try:
self.store.update_event_trigger(
event.event_id,
status="RECEIVED",
channel="workspace_agent",
remote_id=None,
callback_state="SENT",
)
accepted = self.workspace.trigger(event)
except TriggerError as exc:
self._retry_or_dead_letter(
event,
attempts,
str(exc),
trigger_channel="workspace_agent",
trigger_http_status=exc.http_status,
trigger_error_category=exc.category,
)
else:
self.store.update_event_trigger(
event.event_id,
status="AWAITING_DECISION",
channel="workspace_agent",
remote_id=accepted.remote_id,
trigger_http_status=accepted.http_status,
callback_state="AWAITING_CALLBACK",
callback_deadline_after=self.config.callback_timeout_seconds,
)
elif self.responses is not None:
response_id = self.responses.start(event)
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
)
else:
raise TriggerError("no_model_channel")
except TriggerError as exc:
self._retry_or_dead_letter(event, attempts, str(exc))
def process_workspace_timeouts(self) -> None:
cutoff = int(time.time())
for row in self.store.claim_workspace_timeouts(
owner=self.worker_id,
older_than=cutoff,
limit=1,
lease_seconds=self.config.lease_seconds,
):
event = Event.from_dict(json.loads(row["event_json"]))
if self.responses is None:
attempts = int(row["callback_attempts"])
if attempts + 1 > self.config.max_retries:
self.store.update_event_trigger(
event.event_id, status="DEAD_LETTER",
channel="workspace_agent", remote_id=None,
callback_state="DEAD_LETTER",
increment_callback_attempt=True,
)
else:
self.store.update_event_trigger(
event.event_id, status="AWAITING_DECISION",
channel="workspace_agent", remote_id=None,
callback_state="AWAITING_CALLBACK",
callback_deadline_after=self._backoff(attempts),
increment_callback_attempt=True,
)
continue
try:
response_id = self.responses.start(event)
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
increment_attempt=True,
callback_state="AWAITING_CALLBACK",
callback_deadline_after=self.config.callback_timeout_seconds,
)
except TriggerError as exc:
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
def poll_background_responses(self) -> None:
if self.responses is None:
return
for row in self.store.claim_response_polls(
owner=self.worker_id,
limit=1,
lease_seconds=self.config.lease_seconds,
):
event = Event.from_dict(json.loads(row["event_json"]))
response_id = row.get("remote_id")
if not isinstance(response_id, str):
self._retry_or_dead_letter(event, int(row["attempts"]), "missing_response_id")
continue
try:
status, decision = self.responses.poll(response_id)
if status in {"queued", "in_progress"}:
attempts = int(row["attempts"])
if attempts + 1 > self.config.max_retries:
self._retry_or_dead_letter(
event, attempts, f"response_{status}_retry_limit"
)
continue
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
increment_attempt=True,
)
continue
if decision is None:
raise TriggerError("completed_without_decision")
self.submit_decision(decision.to_dict())
except OrchestratorError as exc:
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
def dispatch_work_units(self) -> None:
rows = self.store.claim_outbox(
owner=self.worker_id,
limit=64,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
try:
outbox_path, queue_path = self.dispatch.dispatch(row)
self.store.mark_outbox(row["work_unit_id"], status="DISPATCHED")
payload = {
"assigned_agent": row["assigned_agent"],
"event_id": row["event_id"],
"dispatch_outbox": str(outbox_path),
"command_queue": str(queue_path),
}
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=self._project_for_event(row["event_id"]),
kind="TASK_DISPATCHED",
work_unit_id=row["work_unit_id"],
payload=payload,
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=record["project_key"],
work_unit_id=row["work_unit_id"],
kind=record["kind"],
payload=record,
)
except Exception:
attempts = int(row["attempts"])
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
self.store.mark_outbox(
row["work_unit_id"],
status=status,
retry_after=self._backoff(attempts),
increment_attempt=True,
)
LOG.exception("dispatch_failed work_unit=%s", row["work_unit_id"])
def synchronize_openclaw(self) -> None:
rows = self.store.claim_lifecycle(
owner=self.worker_id,
limit=64,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
try:
payload = json.loads(row["payload_json"])
self.synchronizer.emit(payload)
self.store.mark_lifecycle(row["sync_id"], status="SENT")
except Exception:
attempts = int(row["attempts"])
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
self.store.mark_lifecycle(
row["sync_id"],
status=status,
retry_after=self._backoff(attempts),
increment_attempt=True,
)
LOG.exception("openclaw_sync_failed sync_id=%s", row["sync_id"])
def enqueue_periodic_summaries(self) -> None:
for state in self.store.all_project_states():
project_key = state["project_key"]
if not self.store.summary_due(project_key, self.config.summary_interval_seconds):
continue
summary = self.store.project_summary(project_key)
if summary is None:
continue
digest = hashlib.sha256(
json.dumps(summary, separators=(",", ":"), sort_keys=True).encode()
).hexdigest()
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=project_key,
kind="PROJECT_SUMMARY",
work_unit_id=None,
payload=summary,
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=project_key,
work_unit_id=None,
kind=record["kind"],
payload=record,
)
self.store.mark_summary(project_key, digest)
def _enqueue_event_lifecycle(self, event: Event) -> None:
if event.event_type == "work_unit_started":
kind = "TASK_STARTED"
elif event.event_type in {"work_unit_completed", "work_unit_failed", "work_unit_blocked"}:
kind = "TASK_ENDED"
else:
kind = "EVENT_RECEIVED"
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=event.project_key,
kind=kind,
work_unit_id=event.work_unit_id,
payload=event.to_dict(),
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=event.project_key,
work_unit_id=event.work_unit_id,
kind=kind,
payload=record,
)
def _enqueue_decision_lifecycle(self, decision: Decision, version: int) -> None:
kind = "PROJECT_COMPLETED" if decision.verdict == "COMPLETE" else "DECISION_APPLIED"
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=decision.project_key,
kind=kind,
work_unit_id=None,
payload={"decision": decision.to_dict(), "state_version": version},
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=decision.project_key,
work_unit_id=None,
kind=kind,
payload=record,
)
summary = self.store.project_summary(decision.project_key)
if summary:
summary_record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=decision.project_key,
kind="PROJECT_SUMMARY",
work_unit_id=None,
payload=summary,
)
self.store.enqueue_lifecycle(
sync_id=summary_record["sync_id"],
project_key=decision.project_key,
work_unit_id=None,
kind=summary_record["kind"],
payload=summary_record,
)
def _project_for_event(self, event_id: str) -> str:
rows = self.store._conn().execute(
"SELECT project_key FROM events WHERE event_id=?",
(event_id,),
).fetchone()
if rows is None:
raise ConflictError("event_not_found")
return str(rows["project_key"])
def _retry_or_dead_letter(
self,
event: Event,
attempts: int,
reason: str,
*,
trigger_channel: str | None = None,
trigger_http_status: int | None = None,
trigger_error_category: str | None = None,
) -> None:
next_attempt = attempts + 1
if next_attempt > self.config.max_retries:
self.store.update_event_trigger(
event.event_id,
status="DEAD_LETTER",
channel=trigger_channel,
remote_id=None,
increment_attempt=True,
trigger_http_status=trigger_http_status,
trigger_error_category=trigger_error_category,
)
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=event.project_key,
kind="TASK_ENDED",
work_unit_id=event.work_unit_id,
payload={
"status": "FAIL",
"reason": reason,
"event_id": event.event_id,
"attempts": next_attempt,
},
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=event.project_key,
work_unit_id=event.work_unit_id,
kind=record["kind"],
payload=record,
)
return
self.store.update_event_trigger(
event.event_id,
status="TRIGGER_FAILED",
channel=trigger_channel,
remote_id=None,
retry_after=self._backoff(attempts),
increment_attempt=True,
trigger_http_status=trigger_http_status,
trigger_error_category=trigger_error_category,
)
def _backoff(self, attempts: int) -> int:
return min(3600, self.config.retry_base_seconds * (2 ** min(attempts, 10)))
class Handler(BaseHTTPRequestHandler):
runtime: Runtime
def log_message(self, fmt: str, *args: object) -> None:
LOG.info("%s %s", self.client_address[0], fmt % args)
def _reply(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict[str, Any]:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as exc:
raise ValidationError("invalid_content_length") from exc
if length <= 0 or length > 1_048_576:
raise ValidationError("invalid_body_size")
try:
value = json.loads(self.rfile.read(length))
except json.JSONDecodeError as exc:
raise ValidationError("invalid_json") from exc
if not isinstance(value, dict):
raise ValidationError("json_root_not_object")
return value
def _authenticate(self, expected: str) -> None:
header = self.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise AuthenticationError("missing_bearer")
if not hmac.compare_digest(header[7:], expected):
raise AuthenticationError("invalid_bearer")
def do_GET(self) -> None:
path = urlparse(self.path).path.rstrip("/") or "/"
try:
if path == "/healthz":
self._reply(
200,
{
"service": "aios-orchestrator-gateway",
"status": "active",
"contract_commit": self.runtime.config.contract_commit,
"polling": True,
},
)
return
if path.startswith("/v1/orchestrator/state/"):
self._authenticate(self.runtime.config.inbound_token())
key = path.rsplit("/", 1)[-1]
state = self.runtime.store.project_summary(key)
self._reply(200 if state else 404, state or {"error": "project_not_found"})
return
if path.startswith("/v1/orchestrator/events/"):
self._authenticate(self.runtime.config.inbound_token())
event_id = path.rsplit("/", 1)[-1]
if not ID_RE.fullmatch(event_id):
raise ValidationError("invalid_event_id")
status = self.runtime.store.event_status(event_id)
self._reply(200 if status else 404, status or {"error": "event_not_found"})
return
self._reply(404, {"error": "not_found"})
except AuthenticationError as exc:
self._reply(401, {"error": str(exc)})
except OrchestratorError as exc:
self._reply(400, {"error": str(exc)})
def do_POST(self) -> None:
path = urlparse(self.path).path.rstrip("/")
try:
if path == "/v1/orchestrator/events":
self._authenticate(self.runtime.config.inbound_token())
created, event = self.runtime.submit_event(self._body())
self._reply(202 if created else 200, {"created": created, "event": event})
return
if path == "/v1/orchestrator/decisions":
self._authenticate(self.runtime.config.callback_token())
version = self.runtime.submit_decision(self._body())
self._reply(202, {"accepted": True, "state_version": version})
return
if path == "/v1/orchestrator/poll":
self._authenticate(self.runtime.config.inbound_token())
self.runtime.poll_once()
self._reply(200, {"polled": True})
return
self._reply(404, {"error": "not_found"})
except AuthenticationError as exc:
self._reply(401, {"error": str(exc)})
except ConflictError as exc:
self._reply(409, {"error": str(exc)})
except ValidationError as exc:
self._reply(422, {"error": str(exc)})
except OrchestratorError as exc:
self._reply(400, {"error": str(exc)})
except Exception:
LOG.exception("request_failed path=%s", path)
self._reply(500, {"error": "internal_error"})
def serve(config_path: Path) -> None:
config = Config.from_file(config_path)
runtime = Runtime(config)
Handler.runtime = runtime
server = ThreadingHTTPServer((config.host, config.port), Handler)
runtime.start()
LOG.info(
"gateway_started host=%s port=%s contract=%s polling=%ss",
config.host,
config.port,
config.contract_commit,
config.poll_interval_seconds,
)
try:
server.serve_forever(poll_interval=0.5)
finally:
server.server_close()
runtime.stop()
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import hashlib
import json
import os
import stat
from pathlib import Path
import urllib.error
import urllib.request
from typing import Any
from .errors import DispatchError
from .securefs import atomic_write_bytes_shared, ensure_shared_directory, read_secure_bytes
class OpenClawSynchronizer:
"""Synchronize task starts, ends, dispatches, and project summaries.
A durable file is always emitted. An optional HTTP endpoint is best-effort;
failure leaves the lifecycle record pending for retry.
"""
def __init__(
self,
queue_dir: Path,
*,
endpoint: str | None = None,
bearer_token: str | None = None,
timeout_seconds: int = 10,
):
self.queue_dir = Path(queue_dir)
self.endpoint = endpoint
self.bearer_token = bearer_token
self.timeout_seconds = timeout_seconds
ensure_shared_directory(self.queue_dir)
def emit(self, record: dict[str, Any]) -> Path:
sync_id = record.get("sync_id")
if not isinstance(sync_id, str) or not sync_id:
raise DispatchError("invalid_sync_id")
payload = (json.dumps(record, indent=2, sort_keys=True) + "\n").encode()
path = self.queue_dir / "pending" / f"{sync_id}.json"
ensure_shared_directory(path.parent)
try:
st = os.lstat(path)
except FileNotFoundError:
atomic_write_bytes_shared(path, payload, mode=0o640)
else:
if not stat.S_ISREG(st.st_mode):
raise DispatchError("sync_target_not_regular")
if read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576) != payload:
raise DispatchError("sync_idempotency_conflict")
if self.endpoint:
headers = {
"Content-Type": "application/json",
"Idempotency-Key": sync_id,
"User-Agent": "aios-orchestrator-gateway/0.2",
}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
request = urllib.request.Request(
self.endpoint,
data=payload,
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
response.read(65_536)
if response.status not in {200, 201, 202, 204}:
raise DispatchError(f"openclaw_sync_http_{response.status}")
except urllib.error.HTTPError as exc:
raise DispatchError(f"openclaw_sync_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise DispatchError("openclaw_sync_failed") from exc
return path
def lifecycle_record(
*,
contract_commit: str,
project_key: str,
kind: str,
work_unit_id: str | None,
payload: dict[str, Any],
) -> dict[str, Any]:
canonical = json.dumps(
{
"contract_commit": contract_commit,
"project_key": project_key,
"kind": kind,
"work_unit_id": work_unit_id,
"payload": payload,
},
separators=(",", ":"),
sort_keys=True,
)
sync_id = hashlib.sha256(canonical.encode()).hexdigest()
return {
"schema_version": 1,
"sync_id": sync_id,
"contract_commit": contract_commit,
"project_key": project_key,
"kind": kind,
"work_unit_id": work_unit_id,
"payload": payload,
}

Some files were not shown because too many files have changed in this diff Show More