Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f7db29b1 | ||
|
|
09c33eb448 | ||
|
|
f7abb8066b | ||
|
|
efc9ffcdc6 | ||
|
|
05b0d7fb80 | ||
|
|
13c2d24e76 | ||
|
|
fbb17597d1 | ||
|
|
7f8261a4b6 | ||
|
|
839e567060 | ||
|
|
db8d9c694d | ||
|
|
7fc2da56d2 | ||
|
|
ae30d716f9 | ||
|
|
d96086b345 | ||
|
|
6c7928c096 | ||
|
|
5585856cd3 | ||
|
|
9ffcf93fcd | ||
|
|
94db629904 | ||
|
|
7f9d664d6b | ||
|
|
90e05be0a9 | ||
|
|
70fbed1050 | ||
|
|
ab268789ee | ||
|
|
35e4a5350f | ||
|
|
dc5dae2a51 | ||
|
|
73c5b2330e | ||
|
|
8aa436043d | ||
|
|
3d4e630582 | ||
|
|
3b609a8e57 | ||
|
|
0a4e2b741d | ||
|
|
e68313f95e | ||
|
|
e62ff487ab | ||
|
|
680a2f4f2b | ||
|
|
8d6d92bcec | ||
|
|
3a5fc56c9d | ||
|
|
6327664d22 | ||
|
|
228d8fedf2 | ||
|
|
f94af50497 | ||
|
|
2a81d8ced4 | ||
|
|
759e343075 | ||
|
|
bef2de2c66 | ||
|
|
e8bd867c33 | ||
|
|
1953cacebc | ||
|
|
43be07af73 | ||
|
|
bdea3ef5ba | ||
|
|
76848939e9 | ||
|
|
f2a08293dd | ||
|
|
866da79a81 | ||
|
|
8a810c2ae8 | ||
|
|
c475a8238c | ||
|
|
24c55564a0 | ||
|
|
55a4b32aa6 | ||
|
|
42c4d11457 | ||
|
|
7be2107ad0 | ||
|
|
4587c9c46d | ||
|
|
aebff86864 | ||
|
|
76d2453ef3 |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"approval_id": "owner-preapprove-V1R1C5-main-entry-20260707",
|
||||
"created_at": "2026-07-07T00:00:00Z",
|
||||
"approved_by_role": "owner",
|
||||
"target_branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"target_base": "main",
|
||||
"target_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"allowed_actions": [
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline"
|
||||
],
|
||||
"risk_acceptance": "Owner approved promotion of autonomous GitOps platform control plane to main, subject to validation, OpenClaw supervision, no secret leakage, no direct force push, and rollback target V1R1C4.",
|
||||
"expires_at": "2026-07-14T23:59:59Z",
|
||||
"constraints": {
|
||||
"direct_main_push": false,
|
||||
"force_push": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": false,
|
||||
"validation_required": true,
|
||||
"owner_notification_required": true,
|
||||
"web_ui_audit_required": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "aios-autonomous-merge-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "gated-autonomous",
|
||||
"description": "Defines when Codex CI may mark a patch branch as auto-merge eligible and when main merge must remain blocked.",
|
||||
"branch_scope": {
|
||||
"allowed_source_patterns": [
|
||||
"patch/*"
|
||||
],
|
||||
"protected_targets": [
|
||||
"main"
|
||||
],
|
||||
"direct_main_push": false
|
||||
},
|
||||
"merge_gate": {
|
||||
"required_status": [
|
||||
"validation_passed",
|
||||
"acceptance_required_passed",
|
||||
"secret_scan_passed",
|
||||
"policy_scan_passed",
|
||||
"indirect_regression_passed",
|
||||
"gray_deployment_passed"
|
||||
],
|
||||
"required_artifacts": [
|
||||
"reports/codex/validation-report.json",
|
||||
"reports/codex/merge-candidate.json",
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
],
|
||||
"required_approval": {
|
||||
"owner_or_admin": true,
|
||||
"audit_record_required": true,
|
||||
"approval_request_type": "release",
|
||||
"approval_target": "V1R1C5"
|
||||
}
|
||||
},
|
||||
"autonomous_behavior": {
|
||||
"auto_validate": true,
|
||||
"auto_self_heal": true,
|
||||
"auto_create_merge_candidate": true,
|
||||
"auto_merge_to_main": "only_after_policy_and_owner_approval",
|
||||
"auto_tag_after_merge": false,
|
||||
"max_self_heal_iterations": 5
|
||||
},
|
||||
"hard_blocks": [
|
||||
"secret_leak_detected",
|
||||
"direct_main_write_detected",
|
||||
"destructive_operation_detected",
|
||||
"baseline_active_version_changed_before_merge",
|
||||
"auth_or_gitea_security_config_modified",
|
||||
"required_test_failed",
|
||||
"unapproved_high_risk_change"
|
||||
],
|
||||
"post_merge": {
|
||||
"proposed_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"tag_creation": "request_only",
|
||||
"baseline_activation": "owner_approved_action_only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "aios-bounded-autonomous-production-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-lights-out-production",
|
||||
"description": "Allows unattended CI/self-healing/canary within pre-approved policy boundaries while preserving production safety gates.",
|
||||
"non_negotiable_controls": {
|
||||
"direct_main_push": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": "forbidden",
|
||||
"rollback_execution": "request_or_preapproved_runbook_only",
|
||||
"tag_creation": "request_or_preapproved_runbook_only",
|
||||
"baseline_activation": "preapproved_runbook_only",
|
||||
"force_push": false,
|
||||
"delete_tags_or_history": false
|
||||
},
|
||||
"autonomous_scope": {
|
||||
"allowed_without_human_intervention": [
|
||||
"static_validation",
|
||||
"acceptance_validation",
|
||||
"secret_scan",
|
||||
"policy_scan",
|
||||
"self_healing_patch_on_same_branch",
|
||||
"merge_candidate_generation",
|
||||
"gray_deployment_validation",
|
||||
"failure_report_generation",
|
||||
"rollback_request_generation",
|
||||
"baseline_proposal_generation"
|
||||
],
|
||||
"requires_preapproved_runbook": [
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline",
|
||||
"production_runtime_change",
|
||||
"production_rollback_execution"
|
||||
],
|
||||
"always_blocked": [
|
||||
"force_push",
|
||||
"purge_baseline",
|
||||
"delete_version_history",
|
||||
"delete_audit_records",
|
||||
"commit_real_secret",
|
||||
"disable_auth_or_gitea_protection"
|
||||
]
|
||||
},
|
||||
"preapproval_contract": {
|
||||
"path_pattern": "approvals/preapproved/*.json",
|
||||
"required_fields": [
|
||||
"approval_id",
|
||||
"approved_by_role",
|
||||
"target_branch",
|
||||
"target_version",
|
||||
"expires_at",
|
||||
"allowed_actions",
|
||||
"risk_acceptance",
|
||||
"rollback_target"
|
||||
],
|
||||
"required_values": {
|
||||
"approved_by_role": ["owner", "admin"],
|
||||
"target_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4"
|
||||
}
|
||||
},
|
||||
"promotion_pipeline": [
|
||||
"validate",
|
||||
"self_heal_until_pass",
|
||||
"generate_merge_candidate",
|
||||
"gray_deploy_validate",
|
||||
"check_preapproval",
|
||||
"merge_or_request_approval",
|
||||
"propose_or_activate_baseline",
|
||||
"monitor",
|
||||
"auto_generate_rollback_request_on_failure"
|
||||
],
|
||||
"slo_gates": {
|
||||
"gray_deployment_required": true,
|
||||
"error_budget_policy": "block_if_unknown",
|
||||
"observability_required": true,
|
||||
"post_merge_monitoring_window": "30m"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "aios-multi-agent-consensus-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-autonomous-consensus",
|
||||
"description": "Production-safe consensus policy for OpenClaw, Codex and ChatGPT agents supervising AIOS GitOps changes.",
|
||||
"agents": {
|
||||
"openclaw": {
|
||||
"role": "supervisor",
|
||||
"weight": 2,
|
||||
"required_for_high_risk": true,
|
||||
"responsibility": [
|
||||
"execution supervision",
|
||||
"policy compliance",
|
||||
"failure containment",
|
||||
"owner-facing status certification"
|
||||
]
|
||||
},
|
||||
"codex": {
|
||||
"role": "validator_and_repairer",
|
||||
"weight": 2,
|
||||
"required_for_high_risk": true,
|
||||
"responsibility": [
|
||||
"static validation",
|
||||
"acceptance validation",
|
||||
"self-healing patch loop",
|
||||
"merge candidate generation"
|
||||
]
|
||||
},
|
||||
"chatgpt": {
|
||||
"role": "operator_and_summary_agent",
|
||||
"weight": 1,
|
||||
"required_for_high_risk": false,
|
||||
"responsibility": [
|
||||
"change explanation",
|
||||
"owner notification summary",
|
||||
"repair proposal generation",
|
||||
"runbook authoring"
|
||||
]
|
||||
}
|
||||
},
|
||||
"decision_rules": {
|
||||
"low_risk": {
|
||||
"minimum_score": 2,
|
||||
"required_agents": ["codex"],
|
||||
"allowed_outcome": "auto_merge_candidate"
|
||||
},
|
||||
"medium_risk": {
|
||||
"minimum_score": 3,
|
||||
"required_agents": ["codex", "openclaw"],
|
||||
"allowed_outcome": "auto_merge_candidate_after_gray"
|
||||
},
|
||||
"high_risk": {
|
||||
"minimum_score": 5,
|
||||
"required_agents": ["codex", "openclaw"],
|
||||
"allowed_outcome": "production_request_or_preapproved_execution"
|
||||
}
|
||||
},
|
||||
"hard_blocks": [
|
||||
"credential_material_detected",
|
||||
"direct_main_push_detected",
|
||||
"destructive_history_operation",
|
||||
"auth_protection_disabled",
|
||||
"gitea_protection_disabled",
|
||||
"baseline_activation_without_approval",
|
||||
"owner_notification_failed_for_main_change",
|
||||
"required_acceptance_test_failed"
|
||||
],
|
||||
"required_artifacts": {
|
||||
"validation": "reports/codex/validation-report.json",
|
||||
"openclaw": "reports/openclaw/final-supervision-report.json",
|
||||
"consensus": "reports/consensus/decision.json",
|
||||
"merge_candidate": "reports/codex/merge-candidate.json",
|
||||
"baseline_proposal": "versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
},
|
||||
"production_safety": {
|
||||
"direct_main_push": false,
|
||||
"tag_creation": "request_or_preapproved_only",
|
||||
"baseline_activation": "request_or_preapproved_only",
|
||||
"rollback_execution": "request_or_preapproved_only",
|
||||
"owner_notification": "mandatory_for_main_version_change",
|
||||
"web_ui_audit_record": "mandatory"
|
||||
},
|
||||
"outputs": {
|
||||
"approved": "merge_candidate_ready",
|
||||
"rejected": "failure_report_required",
|
||||
"blocked": "owner_review_required"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "aios-self-governing-devops-policy",
|
||||
"version": "1.0.0",
|
||||
"mode": "bounded-self-governing",
|
||||
"description": "Self-governing DevOps policy for AIOS GitOps with dynamic agent weights, drift detection, guarded rollback requests, policy runtime and skill evolution.",
|
||||
"non_negotiable_controls": {
|
||||
"direct_main_push": false,
|
||||
"all_writes_require_git_commit": true,
|
||||
"real_credentials_in_git": false,
|
||||
"disable_auth_protection": false,
|
||||
"delete_audit_or_version_history": false,
|
||||
"production_execution_without_policy": false
|
||||
},
|
||||
"governance_loop": [
|
||||
"observe_events",
|
||||
"evaluate_policy",
|
||||
"score_agents",
|
||||
"detect_consensus_drift",
|
||||
"render_decision_graph",
|
||||
"run_validation_or_repair",
|
||||
"generate_candidate_or_request",
|
||||
"notify_owner",
|
||||
"persist_audit",
|
||||
"learn_from_outcome",
|
||||
"propose_skill_update"
|
||||
],
|
||||
"agent_learning": {
|
||||
"enabled": true,
|
||||
"weight_bounds": {
|
||||
"openclaw": [1, 4],
|
||||
"codex": [1, 4],
|
||||
"chatgpt": [1, 3]
|
||||
},
|
||||
"initial_weights": {
|
||||
"openclaw": 2,
|
||||
"codex": 2,
|
||||
"chatgpt": 1
|
||||
},
|
||||
"signals": {
|
||||
"validation_accuracy": 0.35,
|
||||
"repair_success": 0.25,
|
||||
"false_positive_rate": -0.15,
|
||||
"owner_override": -0.2,
|
||||
"post_merge_stability": 0.25
|
||||
},
|
||||
"update_policy": "bounded_exponential_smoothing",
|
||||
"write_target": "runtime/governance/agent-weights.json"
|
||||
},
|
||||
"drift_detection": {
|
||||
"enabled": true,
|
||||
"thresholds": {
|
||||
"consensus_score_drop": 2,
|
||||
"agent_disagreement_ratio": 0.5,
|
||||
"repeated_failure_count": 3,
|
||||
"owner_override_count": 2
|
||||
},
|
||||
"on_drift": "block_production_and_request_owner_review"
|
||||
},
|
||||
"policy_runtime": {
|
||||
"required_checks": [
|
||||
"no_direct_main_write",
|
||||
"no_real_credentials",
|
||||
"no_destructive_history_change",
|
||||
"acceptance_tests_pass",
|
||||
"owner_notice_for_main_change",
|
||||
"rollback_target_defined",
|
||||
"baseline_proposal_exists"
|
||||
],
|
||||
"decision_outputs": [
|
||||
"allow_candidate",
|
||||
"conditional_candidate",
|
||||
"request_owner_review",
|
||||
"block"
|
||||
]
|
||||
},
|
||||
"guarded_rollback": {
|
||||
"mode": "request_first",
|
||||
"automatic_action": "create_rollback_request_and_notify_owner",
|
||||
"execution": "requires_preapproved_runbook_or_owner_action",
|
||||
"default_target": "V1R1C4"
|
||||
},
|
||||
"skill_evolution": {
|
||||
"enabled": true,
|
||||
"mode": "propose_only",
|
||||
"sources": [
|
||||
"validation_failures",
|
||||
"owner_feedback",
|
||||
"post_merge_incidents",
|
||||
"repeated_manual_repairs"
|
||||
],
|
||||
"output_dir": "skills/proposals",
|
||||
"activation": "merge_after_acceptance"
|
||||
},
|
||||
"owner_control": {
|
||||
"channels": ["web_ui", "wechat_or_wecom"],
|
||||
"actions": ["audit", "approve", "reject", "request_change", "request_rollback"],
|
||||
"main_version_change_notice": "mandatory"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "self-healing-gitops",
|
||||
"version": "1.0.0",
|
||||
"mode": "codex-driven-ci",
|
||||
"description": "Defines how Codex + CI loop detects failures, generates repair plan, and re-runs validation.",
|
||||
"workflow": {
|
||||
"trigger": ["push", "pull_request"],
|
||||
"validation_script": "scripts/validate-gitops-platform-ui.sh",
|
||||
"failure_handling": {
|
||||
"create_failure_report": true,
|
||||
"report_path": "reports/codex/failure.json",
|
||||
"notify": "codex-gpt",
|
||||
"auto_repair": false,
|
||||
"require_human_or_gpt_review": true
|
||||
},
|
||||
"success_handling": {
|
||||
"allow_merge_candidate": true,
|
||||
"propose_baseline_update": "V1R1C5"
|
||||
}
|
||||
},
|
||||
"self_healing_loop": {
|
||||
"steps": [
|
||||
"run_validation",
|
||||
"collect_failures",
|
||||
"send_to_gpt",
|
||||
"apply_patch_to_branch",
|
||||
"rerun_validation",
|
||||
"repeat_until_pass"
|
||||
],
|
||||
"max_iterations": 5,
|
||||
"stop_conditions": [
|
||||
"PASS",
|
||||
"critical_security_failure",
|
||||
"manual_override"
|
||||
]
|
||||
},
|
||||
"constraints": {
|
||||
"no_main_write": true,
|
||||
"no_secret_exposure": true,
|
||||
"approval_required_for_release": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "aios-self-optimizing-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"mode": "hybrid-event-plus-reconciliation",
|
||||
"description": "Low-impact orchestrator for ChatGPT/Codex self-optimization, version-change notification, audit, and owner action routing.",
|
||||
"design_principles": {
|
||||
"execution_layer_impact": "minimal",
|
||||
"writes": "git_patch_only",
|
||||
"direct_main_push": false,
|
||||
"secrets_in_git": false,
|
||||
"production_high_risk_actions": "approval_or_preapproved_runbook_only",
|
||||
"user_impact": "prefer_async_sidecar_and_read_only_observation"
|
||||
},
|
||||
"triggers": {
|
||||
"primary": {
|
||||
"type": "event_driven",
|
||||
"sources": [
|
||||
"git_push",
|
||||
"tag_created",
|
||||
"baseline_changed",
|
||||
"patch_created",
|
||||
"validation_failed",
|
||||
"validation_passed",
|
||||
"merge_candidate_created",
|
||||
"runtime_health_degraded"
|
||||
],
|
||||
"target": "orchestrator_queue"
|
||||
},
|
||||
"fallback": {
|
||||
"type": "scheduled_reconciliation",
|
||||
"intervals": {
|
||||
"codex_branch_scan": "*/15 * * * *",
|
||||
"chatgpt_patch_scan": "*/15 * * * *",
|
||||
"main_version_scan": "*/5 * * * *",
|
||||
"notification_retry_scan": "*/10 * * * *",
|
||||
"daily_owner_digest": "0 9 * * *"
|
||||
},
|
||||
"purpose": "Catch missed events, reconcile web UI records, and retry failed notifications."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"codex": {
|
||||
"responsibility": [
|
||||
"code validation",
|
||||
"static/security/policy tests",
|
||||
"self-healing patch loop",
|
||||
"merge candidate report"
|
||||
],
|
||||
"trigger": "event_and_schedule",
|
||||
"skill": "skills/gitops-platform-validation/SKILL.md"
|
||||
},
|
||||
"chatgpt": {
|
||||
"responsibility": [
|
||||
"operator summary",
|
||||
"failure triage",
|
||||
"GitOps patch authoring",
|
||||
"owner-facing change explanation"
|
||||
],
|
||||
"trigger": "event_and_schedule",
|
||||
"skill": "skills/gitops-owner-notification/SKILL.md"
|
||||
}
|
||||
},
|
||||
"change_summary_required_fields": [
|
||||
"event_id",
|
||||
"branch",
|
||||
"from_rev",
|
||||
"to_rev",
|
||||
"from_version",
|
||||
"to_version",
|
||||
"elapsed_since_previous_main_change",
|
||||
"changed_features",
|
||||
"code_lines_added",
|
||||
"code_lines_deleted",
|
||||
"config_lines_added",
|
||||
"config_lines_deleted",
|
||||
"test_status",
|
||||
"risk",
|
||||
"approval_status",
|
||||
"rollback_target",
|
||||
"web_ui_audit_url"
|
||||
],
|
||||
"owner_channels": {
|
||||
"wechat": {
|
||||
"enabled": true,
|
||||
"provider": "wechat_or_wecom",
|
||||
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook",
|
||||
"signing_secret_ref": "secret_ref://aios/notifications/owner-action-signing-key",
|
||||
"message_types": [
|
||||
"main_version_changed",
|
||||
"merge_candidate_ready",
|
||||
"validation_failed",
|
||||
"rollback_request_created",
|
||||
"approval_required",
|
||||
"daily_digest"
|
||||
],
|
||||
"actions": [
|
||||
"view_audit",
|
||||
"approve_preapproved_runbook",
|
||||
"request_rollback",
|
||||
"request_change",
|
||||
"reject_release"
|
||||
]
|
||||
},
|
||||
"web_ui": {
|
||||
"enabled": true,
|
||||
"audit_route": "/portal/me/#gitops-audit",
|
||||
"owner_action_route": "/portal/me/#gitops-owner-actions",
|
||||
"record_store": "runtime/gitops/events/*.json"
|
||||
}
|
||||
},
|
||||
"version_change_policy": {
|
||||
"main_change_notification": "mandatory",
|
||||
"notify_owner_on_every_main_version_change": true,
|
||||
"include_diff_stats": true,
|
||||
"include_elapsed_time_since_previous_main_change": true,
|
||||
"include_function_summary": true,
|
||||
"write_web_ui_record": true,
|
||||
"retry_until_delivered_or_acknowledged": true
|
||||
},
|
||||
"owner_action_policy": {
|
||||
"wechat_actions": "signed_short_link_or_command_token",
|
||||
"web_actions": "login_required_portal_action",
|
||||
"all_actions_audited": true,
|
||||
"high_risk_actions": "request_only_unless_preapproved_runbook",
|
||||
"rollback": "create_request_by_default",
|
||||
"change_request": "create_patch_or_plan_by_default"
|
||||
},
|
||||
"minimal_execution_layer_changes": [
|
||||
"Add sidecar orchestrator queue consumer.",
|
||||
"Add event writer for GitOps version changes.",
|
||||
"Add notification sender using secret_ref only.",
|
||||
"Add Web UI audit/action records reading event files.",
|
||||
"Keep existing /api/base /api/ver /api/diff /api/patch /api/model /api/run stable."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "v1r1-execution-api-runtime",
|
||||
"version": "1.0.0",
|
||||
"mode": "api_runtime_not_filesystem",
|
||||
"baseline": "V1R1C4",
|
||||
"target_proposal": "V1R1C5",
|
||||
"description": "Execution API runtime contract for AIOS Xiamayiti self-evolution loop. All production execution requests must go through the V1R1 runtime API rather than direct filesystem or protected credential access.",
|
||||
"security": {
|
||||
"direct_runtime_secret_access": false,
|
||||
"direct_filesystem_production_write": false,
|
||||
"all_execution_audited": true,
|
||||
"owner_control_required_for_high_risk": true,
|
||||
"direct_main_push": false
|
||||
},
|
||||
"api": {
|
||||
"submit_execution_request": "POST /api/v1r1/execution/request",
|
||||
"get_execution_status": "GET /api/v1r1/execution/status/{request_id}",
|
||||
"append_audit": "POST /api/v1r1/audit/append",
|
||||
"send_owner_notification": "POST /api/v1r1/notification/owner",
|
||||
"submit_owner_action": "POST /api/v1r1/owner/action"
|
||||
},
|
||||
"allowed_request_types": [
|
||||
"audit_record",
|
||||
"owner_notification",
|
||||
"evolution_finding",
|
||||
"evolution_plan",
|
||||
"validation_run",
|
||||
"merge_candidate",
|
||||
"release_request",
|
||||
"rollback_request",
|
||||
"baseline_activation_request"
|
||||
],
|
||||
"blocked_request_types": [
|
||||
"write_protected_credential",
|
||||
"force_push",
|
||||
"delete_history",
|
||||
"delete_audit",
|
||||
"disable_auth",
|
||||
"disable_gitea_protection",
|
||||
"direct_main_push"
|
||||
],
|
||||
"execution_states": [
|
||||
"received",
|
||||
"policy_checked",
|
||||
"accepted",
|
||||
"queued",
|
||||
"executed_as_request",
|
||||
"blocked",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "v1r1-existing-wechat-runtime-integration",
|
||||
"version": "1.0.0",
|
||||
"baseline": "V1R1C4",
|
||||
"description": "Integrates AIOS self-evolution notifications with the already deployed Xiamayiti/V1R1 personal WeChat runtime instead of creating a duplicate gateway.",
|
||||
"detected_runtime_processes": [
|
||||
{
|
||||
"process_hint": "/data/agent/services/ucc-wechat-qr-adapter/adapter.py",
|
||||
"role": "user-control-center personal WeChat QR adapter",
|
||||
"integration_priority": 1
|
||||
},
|
||||
{
|
||||
"process_hint": "scripts/wechat_notify_worker.py",
|
||||
"role": "existing personal WeChat notification worker",
|
||||
"integration_priority": 2
|
||||
}
|
||||
],
|
||||
"preferred_integration": {
|
||||
"mode": "existing_worker_queue_bridge",
|
||||
"runtime_env": "AIOS_V1R1_WECHAT_BRIDGE_OUTBOX",
|
||||
"behavior": "Write normalized owner notification JSON into the existing V1R1 WeChat worker queue directory. The existing V1R1 worker is responsible for actual personal WeChat delivery."
|
||||
},
|
||||
"fallback_integration": {
|
||||
"mode": "http_endpoint",
|
||||
"runtime_env": "AIOS_V1R1_WECHAT_OUTBOUND_URL",
|
||||
"behavior": "POST normalized owner notification JSON to a runtime-provided V1R1 notification API endpoint."
|
||||
},
|
||||
"rules": {
|
||||
"do_not_duplicate_wechat_gateway": true,
|
||||
"do_not_store_credentials_in_git": true,
|
||||
"do_not_access_runtime_secrets_directly": true,
|
||||
"web_ui_and_wechat_share_event_id": true,
|
||||
"delivery_status_must_be_audited": true
|
||||
},
|
||||
"operator_discovery_commands": [
|
||||
"ps aux | grep -E 'ucc-wechat|wechat_notify'",
|
||||
"pwdx <pid>",
|
||||
"tr '\\0' '\\n' < /proc/<pid>/environ",
|
||||
"grep -R \"queue\|outbox\|notify\|send\|wechat\" -n /data/agent /data/aios-gitops/workspace/scripts 2>/dev/null | head -100"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT NOT NULL,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||
ON message_outbox(channel, status, priority, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[AIOS] Installing Autonomous GitOps System..."
|
||||
|
||||
WORKDIR=/data/aios-gitops/workspace
|
||||
ETC=/etc/aios
|
||||
RUNTIME=/data/aios-gitops/runtime
|
||||
SERVICE_USER=aios
|
||||
|
||||
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
echo "[AIOS] Creating system user: $SERVICE_USER"
|
||||
useradd --system --home /data/aios-gitops --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
mkdir -p "$ETC"
|
||||
cp deploy/systemd/aios-autonomous-daemon.env.template "$ETC/aios-autonomous-daemon.env"
|
||||
|
||||
mkdir -p \
|
||||
"$RUNTIME/events" \
|
||||
"$RUNTIME/audit" \
|
||||
"$RUNTIME/requests" \
|
||||
"$RUNTIME/reports/codex" \
|
||||
"$RUNTIME/approvals/preapproved" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/outbox" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/inbox" \
|
||||
"$RUNTIME/integrations/v1r1-personal-wechat/status"
|
||||
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /data/aios-gitops/runtime || true
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$WORKDIR" || true
|
||||
|
||||
cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
||||
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable aios-autonomous-daemon.service || true
|
||||
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||
|
||||
systemctl restart aios-autonomous-daemon.service || true
|
||||
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||
|
||||
sleep 2
|
||||
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||
systemctl status aios-v1r1-wechat-worker.service --no-pager || true
|
||||
|
||||
echo "[AIOS] Installation complete"
|
||||
@@ -0,0 +1,16 @@
|
||||
# AIOS Autonomous GitOps Daemon runtime environment template
|
||||
# Copy to /etc/aios/aios-autonomous-daemon.env during deployment.
|
||||
# This template stores only paths and secret_ref identifiers. Runtime values are injected by the host secret manager.
|
||||
|
||||
AIOS_REPO_ROOT=/data/aios-gitops/workspace
|
||||
AIOS_EVENT_DIR=/data/aios-gitops/runtime/events
|
||||
AIOS_AUDIT_DIR=/data/aios-gitops/runtime/audit
|
||||
AIOS_REQUEST_DIR=/data/aios-gitops/runtime/requests
|
||||
AIOS_REPORT_DIR=/data/aios-gitops/runtime/reports/codex
|
||||
AIOS_PREAPPROVAL_DIR=/data/aios-gitops/runtime/approvals/preapproved
|
||||
AIOS_DAEMON_INTERVAL=30
|
||||
AIOS_INGRESS_BIND=127.0.0.1
|
||||
AIOS_INGRESS_PORT=8787
|
||||
AIOS_OWNER_CHANNEL_REF=secret_ref://aios/notifications/owner-wechat-endpoint
|
||||
AIOS_OWNER_ACTION_SIGN_REF=secret_ref://aios/notifications/owner-action-signing
|
||||
AIOS_INGRESS_SIGN_REF=secret_ref://aios/notifications/inbound-event-signing
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous GitOps Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||
ExecStart=/usr/bin/python3 services/aios-autonomous-daemon/daemon.py run
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening (minimal impact)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=AIOS Multi-Agent Consensus Controller
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
ExecStart=/usr/bin/python3 services/aios-consensus-controller/controller.py
|
||||
|
||||
# Hardening (non-invasive)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=AIOS V1R1 Personal WeChat Outbound Worker
|
||||
After=network.target aios-autonomous-daemon.service
|
||||
Wants=aios-autonomous-daemon.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
WorkingDirectory=/data/aios-gitops/workspace
|
||||
EnvironmentFile=/etc/aios/aios-autonomous-daemon.env
|
||||
ExecStart=/usr/bin/python3 services/aios-v1r1-wechat-outbound-worker/worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,210 @@
|
||||
# AIOS 远程 Codex 多 Agent 统筹架构
|
||||
|
||||
## 1. 核心定位
|
||||
|
||||
当前 AIOS / 虾马一体系统的自我进化,不再假设服务器上存在 Codex CLI。
|
||||
|
||||
真实运行模型是:
|
||||
|
||||
- Windows 11 Codex App:总控 / 统筹 / 拆分任务 / 监督多个子 agent;
|
||||
- 服务器 OpenClaw:生产运行侧消息、微信、执行与通道能力;
|
||||
- 服务器 Hermes Agent:运行观测、事件、消息/用户交互、反馈归集;
|
||||
- 服务器 AIOS Autonomous Daemon:GitOps event loop、validation、audit、request;
|
||||
- GitOps 仓库:唯一可审计状态同步面;
|
||||
- WebOps / Portal:owner 审计、审批、回滚、变更入口。
|
||||
|
||||
## 2. 架构图
|
||||
|
||||
```text
|
||||
Windows 11 Codex App
|
||||
├─ Planner Agent
|
||||
├─ Code Patch Agent
|
||||
├─ Validation Agent
|
||||
├─ WeChat/UCC Integration Agent
|
||||
├─ WebOps UI Agent
|
||||
└─ Supervisor Agent
|
||||
↓ git / ssh / reports
|
||||
GitOps Repository: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
↓ pull / event
|
||||
AIOS Server Runtime
|
||||
├─ aios-autonomous-daemon
|
||||
├─ OpenClaw Agent / Gateway
|
||||
├─ Hermes Agent
|
||||
├─ UCC WeChat Adapter
|
||||
├─ WebOps / message_outbox
|
||||
└─ audit / reports / requests
|
||||
↓
|
||||
Owner WebOps + 个人微信
|
||||
```
|
||||
|
||||
## 3. 分工
|
||||
|
||||
### Windows Codex App
|
||||
|
||||
Windows Codex 是统筹器,不是服务器守护进程。
|
||||
|
||||
职责:
|
||||
|
||||
1. 读取 GitOps 指令与当前 branch;
|
||||
2. 启动多个子 agent 并行处理;
|
||||
3. 生成代码/配置/文档 patch;
|
||||
4. push 到同一 GitOps patch 分支;
|
||||
5. 读取服务器回传的验证结果;
|
||||
6. 对失败项继续修复;
|
||||
7. 输出 merge candidate / blocked reason。
|
||||
|
||||
### OpenClaw Agent
|
||||
|
||||
职责:
|
||||
|
||||
1. 维护微信通道、message send、用户通道状态;
|
||||
2. 提供个人微信实际执行能力;
|
||||
3. 接收 message_outbox worker 或 UCC Adapter 的调用;
|
||||
4. 输出 channel status / delivery result;
|
||||
5. 不直接接收业务模块调用。
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
职责:
|
||||
|
||||
1. 汇总系统运行事件;
|
||||
2. 处理用户/消息交互流;
|
||||
3. 将微信或 WebOps 反馈转为系统事件;
|
||||
4. 协助 self-evolution loop 判断用户体验和业务状态;
|
||||
5. 输出观测报告给 GitOps / Codex。
|
||||
|
||||
### AIOS Autonomous Daemon
|
||||
|
||||
职责:
|
||||
|
||||
1. 处理 runtime events;
|
||||
2. 触发 validation / merge gate;
|
||||
3. 写 audit / request / report;
|
||||
4. 不直接改 runtime secrets;
|
||||
5. 不直接调用微信底层适配器。
|
||||
|
||||
## 4. 并行子 Agent 模型
|
||||
|
||||
Windows Codex 可启动以下子 agent:
|
||||
|
||||
| 子 Agent | 任务 |
|
||||
|---|---|
|
||||
| planner | 读取 directive,拆分任务,确认风险 |
|
||||
| server-inspector | 解析服务器命令输出,定位 runtime 状态 |
|
||||
| code-patcher | 修改 GitOps 仓库代码 |
|
||||
| test-author | 补验收用例和验证脚本 |
|
||||
| wechat-integrator | 处理 message_outbox / UCC / OpenClaw 适配 |
|
||||
| webops-ui | 补 WebOps 页面和 API contract |
|
||||
| supervisor | 汇总 PASS/FAIL/BLOCKED |
|
||||
|
||||
## 5. 自动运行机制
|
||||
|
||||
自动运行不代表 Codex 在服务器常驻,而是通过以下 loop 运行:
|
||||
|
||||
```text
|
||||
server event / owner request
|
||||
↓
|
||||
GitOps directive / report
|
||||
↓
|
||||
Windows Codex pulls branch
|
||||
↓
|
||||
Codex child agents patch code/config
|
||||
↓
|
||||
git push
|
||||
↓
|
||||
server git pull + run validation
|
||||
↓
|
||||
reports written
|
||||
↓
|
||||
Codex reads reports
|
||||
↓
|
||||
repeat until PASS/BLOCKED
|
||||
```
|
||||
|
||||
## 6. 通信通道
|
||||
|
||||
### Git
|
||||
|
||||
主要状态同步面:
|
||||
|
||||
- directives;
|
||||
- patches;
|
||||
- code/config;
|
||||
- docs;
|
||||
- acceptance tests;
|
||||
- version proposals。
|
||||
|
||||
### SSH / Operator Logs
|
||||
|
||||
服务器运行结果通过 SSH 或人工粘贴日志回传给 Codex/GPT。
|
||||
|
||||
### Runtime Reports
|
||||
|
||||
服务器写入:
|
||||
|
||||
```text
|
||||
reports/codex/*.json
|
||||
reports/openclaw/*.json
|
||||
reports/hermes/*.json
|
||||
reports/consensus/*.json
|
||||
runtime/gitops/audit/*.json
|
||||
runtime/gitops/requests/*.json
|
||||
```
|
||||
|
||||
## 7. 生产安全边界
|
||||
|
||||
仍然禁止:
|
||||
|
||||
- 直接 main push;
|
||||
- force push;
|
||||
- 写 runtime secrets;
|
||||
- 删除 audit / version history;
|
||||
- 业务模块直接调用 OpenClaw;
|
||||
- 业务模块直接调用 UCC WeChat adapter;
|
||||
- 绕过 message_outbox;
|
||||
- 伪造 sent 状态。
|
||||
|
||||
## 8. 微信链路标准
|
||||
|
||||
生产标准链路:
|
||||
|
||||
```text
|
||||
业务/系统事件
|
||||
→ POST /api/v1/wechat/outbox 或写 message_outbox
|
||||
→ wechat_notify_worker
|
||||
→ wechat_send_backend
|
||||
→ ucc-adapter / openclaw-channel
|
||||
→ 个人微信
|
||||
→ sent/failed/retry/audit
|
||||
```
|
||||
|
||||
当前优先后端:
|
||||
|
||||
```text
|
||||
ucc-adapter
|
||||
```
|
||||
|
||||
OpenClaw channel 仅作为可发现的可选后端,不得硬编码 `openclaw-weixin`。
|
||||
|
||||
## 9. 自我进化目标
|
||||
|
||||
自我进化不是“代码自己改代码”,而是虾马一体系统整体优化:
|
||||
|
||||
1. 业务流程优化;
|
||||
2. 运行健康优化;
|
||||
3. 模型路由优化;
|
||||
4. 通知和审批体验优化;
|
||||
5. WebOps 可观测性优化;
|
||||
6. 必要时生成代码/配置 patch。
|
||||
|
||||
## 10. 进入自动运行的判据
|
||||
|
||||
满足以下条件即认为进入自动运行:
|
||||
|
||||
1. `aios-autonomous-daemon` running;
|
||||
2. OpenClaw/Hermes 进程 running;
|
||||
3. message_outbox 可写;
|
||||
4. Windows Codex 监控目标 branch;
|
||||
5. Codex 可读取 directive 并 push patch;
|
||||
6. 服务器可运行验收命令并写 reports;
|
||||
7. owner 可在 WebOps 或微信收到关键进展。
|
||||
@@ -0,0 +1,154 @@
|
||||
# Codex 验收手册:GitOps Platform UI
|
||||
|
||||
## 目标
|
||||
|
||||
本次变更的目标是把 `cnnetmkt.top` AIOS GitOps Web UI 从基础 dashboard 升级为平台级控制台,形成可审计、可审批、可灰度、可回滚请求的 GitOps 操作入口。
|
||||
|
||||
## 验证分支
|
||||
|
||||
- Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
- Base: `main`
|
||||
- 当前目标基线: `V1R1C4`
|
||||
- 合并后建议新基线: `V1R1C5`
|
||||
|
||||
## 主线差异点
|
||||
|
||||
与 `main` 相比,本分支新增或修改以下直接内容:
|
||||
|
||||
1. `overlays/user-control-center/portal/gitops-dashboard/index.html`
|
||||
- 新增平台级 GitOps 控制台页面。
|
||||
- 包含 Overview、RBAC、Approval、Diff Tree、Patch、Model、Release、Audit 模块。
|
||||
|
||||
2. `overlays/user-control-center/portal/gitops-dashboard/app.js`
|
||||
- 新增浏览器运行时 Platform Control Plane。
|
||||
- 包含 API-first client、RBAC guard、approval fallback、audit local store、diff tree parser、model graph、SSE event hook。
|
||||
|
||||
3. `overlays/user-control-center/portal/gitops-dashboard/styles.css`
|
||||
- 控制台样式。
|
||||
|
||||
4. `overlays/user-control-center/portal/gitops-dashboard/manifest.json`
|
||||
- 控制台 manifest。
|
||||
|
||||
5. `overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json`
|
||||
- UI 侧策略声明。
|
||||
|
||||
6. `overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json`
|
||||
- 后端 API 契约说明。
|
||||
|
||||
7. `patches/patch_web-ui-complete-gitops-dashboard-20260706.branch.json`
|
||||
- GitOps branch 元数据。
|
||||
|
||||
## 间接影响区域
|
||||
|
||||
Codex 验证时必须确认以下间接影响:
|
||||
|
||||
1. Portal 登录入口不应被破坏。
|
||||
- `/portal/me/#gitops`
|
||||
- `/portal/me/#gitops-dashboard`
|
||||
|
||||
2. 现有短链仍应保持登录保护或正常跳转。
|
||||
- `/base`
|
||||
- `/ver`
|
||||
- `/diff`
|
||||
- `/patch`
|
||||
- `/model`
|
||||
- `/run`
|
||||
|
||||
3. 机器 API 不应被前端变更破坏。
|
||||
- `/api/base`
|
||||
- `/api/ver`
|
||||
- `/api/diff`
|
||||
- `/api/patch`
|
||||
- `/api/model`
|
||||
- `/api/run`
|
||||
|
||||
4. 敏感信息不得进入 Git、日志、前端源码或测试产物。
|
||||
- 禁止真实 API key。
|
||||
- 禁止 Bearer token。
|
||||
- 禁止 password / private key。
|
||||
- 允许 `secret_ref://...`。
|
||||
|
||||
5. 高风险操作不得绕过审批。
|
||||
- rollback 只能 request。
|
||||
- tag/release 默认只能 request。
|
||||
- runtime 写入只能 request。
|
||||
- UI 不提供 direct main write。
|
||||
|
||||
## Codex 快速验证命令
|
||||
|
||||
在目标工作区执行:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||
git diff --stat main...HEAD
|
||||
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
```
|
||||
|
||||
## 灰度验证外的必测项目
|
||||
|
||||
除正常灰度页面点击验证外,Codex 必须按 `tests/acceptance/gitops-platform-ui.acceptance.json` 执行以下测试组:
|
||||
|
||||
1. Static Files
|
||||
- 必须存在 index/html/js/css/manifest/policy/contract 文件。
|
||||
|
||||
2. Security
|
||||
- 源码中不得出现真实 secret/token/password。
|
||||
- 模型注册必须只允许 `secret_ref://`。
|
||||
- rollback/tag/runtime/main write 必须 request-only 或 disabled。
|
||||
|
||||
3. UI Direct Impact
|
||||
- 所有 tab id 必须能在 HTML 中找到。
|
||||
- app.js 必须绑定 `DOMContentLoaded`。
|
||||
- app.js 必须包含 `guardAction`、`audit`、`createApproval`、`renderDiffTree`。
|
||||
|
||||
4. API Contract Compatibility
|
||||
- 读接口保持 `/api/base`、`/api/ver`、`/api/run`、`/api/patch`、`/api/model`。
|
||||
- 写接口必须包含 branch、patch/plan、model、model-priority、rollback-request、tag-request。
|
||||
|
||||
5. Indirect Regression
|
||||
- 不得修改 auth/casdoor 配置。
|
||||
- 不得修改 Gitea owner password sync 配置。
|
||||
- 不得修改 baseline active_version。
|
||||
- 不得删除任何版本/tag/patch 历史。
|
||||
|
||||
## 验收判定
|
||||
|
||||
- PASS: 所有 required 测试项通过,且没有 secret 泄漏、没有 direct main write、没有破坏短链/API。
|
||||
- FAIL: 任一 required 测试项失败。
|
||||
- BLOCKED: 需要真实后端或浏览器环境,但静态检查通过;必须列出 blocked 项,不得合并 main。
|
||||
|
||||
## 失败回流规则
|
||||
|
||||
如果验证失败,Codex 必须输出:
|
||||
|
||||
1. 失败测试 ID。
|
||||
2. 失败文件路径。
|
||||
3. 具体失败原因。
|
||||
4. 最小修改建议。
|
||||
5. 是否阻止合并。
|
||||
|
||||
然后把问题交给自定义 GPT 继续修改同一 patch 分支。修复后重复执行完整验收,直到 PASS。
|
||||
|
||||
## 合并规则
|
||||
|
||||
只有满足以下条件才能合并 main:
|
||||
|
||||
1. `scripts/validate-gitops-platform-ui.sh` PASS。
|
||||
2. acceptance JSON 中所有 `required: true` 项 PASS。
|
||||
3. 灰度验证通过。
|
||||
4. 无 secret 泄漏。
|
||||
5. 无 direct main write。
|
||||
6. 已创建或确认合并后基线提案 `V1R1C5`。
|
||||
|
||||
## 合并后基线要求
|
||||
|
||||
合并进入 main 后,必须定义新基线:
|
||||
|
||||
- Version: `V1R1C5`
|
||||
- Purpose: `Promote AIOS GitOps Platform Control Plane UI with Codex validation workflow, acceptance tests, RBAC/approval/audit/release/model control-plane scaffolding.`
|
||||
- Risk: `high`
|
||||
- Approval: `owner/admin required`
|
||||
- Rollback target: `V1R1C4`
|
||||
|
||||
合并后再创建 tag/version,不允许在未合并或未验收通过前直接创建生产 tag。
|
||||
@@ -0,0 +1,351 @@
|
||||
# Codex 指令:修复 V1R1 微信主动发送后端
|
||||
|
||||
Directive ID: `CODEX-FIX-V1R1-WECHAT-BACKEND-20260707`
|
||||
|
||||
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
|
||||
Base: `main`
|
||||
|
||||
Current baseline: `V1R1C4`
|
||||
|
||||
## 1. 当前阻塞
|
||||
|
||||
当前微信主动发送链路已改为:
|
||||
|
||||
```text
|
||||
message_outbox
|
||||
→ wechat_notify_worker.py
|
||||
→ wechat_send_backend.py
|
||||
→ openclaw-channel 或 ucc-adapter
|
||||
```
|
||||
|
||||
实际验证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"backend": "unavailable",
|
||||
"reason": "wechat outbound backend unavailable: openclaw channel not registered; ucc adapter send endpoint not detected"
|
||||
}
|
||||
```
|
||||
|
||||
已确认:
|
||||
|
||||
1. OpenClaw Gateway 运行中,但 `openclaw channels list --json` 只有 `{ "chat": {} }`,没有 wechat/weixin channel。
|
||||
2. 当前生产微信能力优先出口应是 `ucc-wechat-qr-adapter`,不是 OpenClaw channel。
|
||||
3. 当前系统存在进程:`/data/agent/services/ucc-wechat-qr-adapter/adapter.py`。
|
||||
4. 当前服务配置:`/etc/aios/ucc-wechat-qr-adapter.env`。
|
||||
5. 当前监听端口:`AIOS_WECHAT_QR_ADAPTER_PORT=18800`。
|
||||
6. `wechat_send_backend.py` 没有探测到 UCC adapter 的可用发送接口。
|
||||
|
||||
## 2. 修复目标
|
||||
|
||||
Codex 必须补齐 V1R1 微信主动发送后端,使 `message_outbox → worker → ucc-adapter → 个人微信` 成为主路径。
|
||||
|
||||
不得让业务模块直接调用:
|
||||
|
||||
- `openclaw message send`;
|
||||
- OpenClaw Gateway `127.0.0.1:18789`;
|
||||
- 微信适配器底层进程;
|
||||
- 本地 secrets 文件。
|
||||
|
||||
业务/外部系统只允许写入:
|
||||
|
||||
```text
|
||||
message_outbox
|
||||
```
|
||||
|
||||
或调用未来统一 API:
|
||||
|
||||
```text
|
||||
POST /api/v1/wechat/outbox
|
||||
```
|
||||
|
||||
## 3. Codex 必须先执行的检查
|
||||
|
||||
只执行命令,不输出 token。
|
||||
|
||||
### 3.1 检查 adapter 路由和函数
|
||||
|
||||
```bash
|
||||
sudo grep -RniE '@.*route|@app\.(get|post)|FastAPI|Flask|send|message|notify|outbound|wechat|target' \
|
||||
/data/agent/services/ucc-wechat-qr-adapter/adapter.py \
|
||||
| sed -E 's/(token|password|secret|key|auth)([=: ][^ ]+)/\1=***MASKED***/Ig' \
|
||||
| head -n 200
|
||||
```
|
||||
|
||||
### 3.2 检查 adapter 健康接口
|
||||
|
||||
```bash
|
||||
for p in \
|
||||
/health \
|
||||
/status \
|
||||
/api/health \
|
||||
/api/internal/health \
|
||||
/api/internal/wechat/status \
|
||||
/api/internal/wechat/send
|
||||
do
|
||||
echo "=== $p ==="
|
||||
curl -sS "http://127.0.0.1:18800$p" || true
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
### 3.3 如需 token,安全读取但不得打印
|
||||
|
||||
```bash
|
||||
ADAPTER_TOKEN="$(sudo awk -F= '/^AIOS_WECHAT_QR_ADAPTER_TOKEN=/{print $2}' /etc/aios/ucc-wechat-qr-adapter.env)"
|
||||
|
||||
for p in \
|
||||
/health \
|
||||
/status \
|
||||
/api/health \
|
||||
/api/internal/health \
|
||||
/api/internal/wechat/status
|
||||
do
|
||||
echo "=== $p ==="
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer ${ADAPTER_TOKEN}" \
|
||||
-H "X-AIOS-Token: ${ADAPTER_TOKEN}" \
|
||||
"http://127.0.0.1:18800$p" || true
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
## 4. 必须实现
|
||||
|
||||
### 4.1 如果 adapter 已有发送接口
|
||||
|
||||
若 `adapter.py` 已经存在发送接口,Codex 应:
|
||||
|
||||
1. 确认真实路径、请求格式、认证头;
|
||||
2. 修改 `scripts/wechat_send_backend.py` 的 `ucc-adapter` backend 调用该真实接口;
|
||||
3. 保证 token 仅从运行时环境或 env 文件读取,不进入 Git;
|
||||
4. 更新验收文档。
|
||||
|
||||
### 4.2 如果 adapter 没有发送接口
|
||||
|
||||
Codex 应在 `/data/agent/services/ucc-wechat-qr-adapter/adapter.py` 对应源码路径中补内部接口,或在 GitOps 仓库新增 overlay/patch 指令,目标接口为:
|
||||
|
||||
```http
|
||||
POST /api/internal/wechat/send
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "...",
|
||||
"content": "...",
|
||||
"account_id": "...",
|
||||
"message_id": "...",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
认证:
|
||||
|
||||
- 使用 `/etc/aios/ucc-wechat-qr-adapter.env` 中的 `AIOS_WECHAT_QR_ADAPTER_TOKEN`;
|
||||
- 支持 `Authorization: Bearer <token>`;
|
||||
- 支持 `X-AIOS-Token: <token>`;
|
||||
- 不允许打印 token。
|
||||
|
||||
返回:
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"provider_message_id": "...",
|
||||
"status": "sent"
|
||||
}
|
||||
```
|
||||
|
||||
如果底层暂时没有真实发送函数,必须返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"code": "adapter_send_not_supported"
|
||||
}
|
||||
```
|
||||
|
||||
不得假装 `sent`。
|
||||
|
||||
## 5. 修改范围
|
||||
|
||||
允许修改或新增:
|
||||
|
||||
- `scripts/wechat_send_backend.py`
|
||||
- `scripts/wechat_notify_worker.py`
|
||||
- `scripts/enqueue_wechat_notification.py`
|
||||
- `scripts/message_outbox_schema.py`
|
||||
- `docs/ops/*wechat*`
|
||||
- `tests/acceptance/*wechat*`
|
||||
- UCC adapter 的 overlay/patch 文件
|
||||
- systemd/env template 中与 worker 配置相关的字段
|
||||
|
||||
禁止修改:
|
||||
|
||||
- runtime secrets 明文;
|
||||
- 个人微信原始 token;
|
||||
- Gitea 安全配置;
|
||||
- Casdoor/auth 配置;
|
||||
- baseline active version;
|
||||
- 删除 audit/version/history 的逻辑。
|
||||
|
||||
## 6. 必须新增或更新验收用例
|
||||
|
||||
### TC-WX-BE-01
|
||||
|
||||
OpenClaw channel list 只有 `{ "chat": {} }` 时,不得调用 `openclaw-weixin`。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
wechat_send_backend 选择 ucc-adapter 或明确 unavailable
|
||||
last_error 不包含 Unknown channel: openclaw-weixin
|
||||
```
|
||||
|
||||
### TC-WX-BE-02
|
||||
|
||||
UCC adapter 存活时自动选择 `ucc-adapter`。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
python3 scripts/wechat_send_backend.py
|
||||
→ backend=ucc-adapter
|
||||
```
|
||||
|
||||
### TC-WX-BE-03
|
||||
|
||||
outbox 测试消息可进入 worker。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status 从 queued 变为 dispatching,再变为 sent 或 failed
|
||||
```
|
||||
|
||||
### TC-WX-BE-04
|
||||
|
||||
如果 adapter 支持真实发送,owner 微信收到测试消息。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status=sent
|
||||
audit_logs.action=wechat_outbound_sent
|
||||
```
|
||||
|
||||
### TC-WX-BE-05
|
||||
|
||||
如果 adapter 不支持发送,消息不得丢失。
|
||||
|
||||
预期:
|
||||
|
||||
```text
|
||||
message_outbox.status=queued 或 failed
|
||||
last_error 包含 adapter_send_not_supported
|
||||
audit_logs.action=wechat_outbound_failed
|
||||
```
|
||||
|
||||
### TC-WX-SEC-01
|
||||
|
||||
日志不得泄露:
|
||||
|
||||
- token;
|
||||
- password;
|
||||
- secret;
|
||||
- cookie;
|
||||
- 个人微信原始 ID 给普通用户;
|
||||
- route proof;
|
||||
- runtime secrets 路径。
|
||||
|
||||
## 7. 最小验收命令
|
||||
|
||||
### 7.1 检查 backend
|
||||
|
||||
```bash
|
||||
cd /data/aios-gitops/workspace
|
||||
python3 scripts/wechat_send_backend.py
|
||||
```
|
||||
|
||||
### 7.2 入队测试
|
||||
|
||||
```bash
|
||||
python3 scripts/enqueue_wechat_notification.py \
|
||||
--recipient owner \
|
||||
--recipient-role owner \
|
||||
--message-type system_report \
|
||||
--priority high \
|
||||
--title "AIOS 测试通知" \
|
||||
--content "这是通过 message_outbox → ucc-adapter 发送的主动微信通知。"
|
||||
```
|
||||
|
||||
### 7.3 执行 worker
|
||||
|
||||
```bash
|
||||
python3 scripts/wechat_notify_worker.py --once
|
||||
```
|
||||
|
||||
### 7.4 查询状态
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import sqlite3
|
||||
con=sqlite3.connect('/data/aios-gitops/workspace/data/webops.sqlite3')
|
||||
con.row_factory=sqlite3.Row
|
||||
print('message_outbox')
|
||||
for r in con.execute('select id,status,retry_count,last_error,sent_at from message_outbox order by created_at desc limit 5'):
|
||||
print(dict(r))
|
||||
print('audit_logs')
|
||||
for r in con.execute('select action,target,detail,created_at from audit_logs order by created_at desc limit 5'):
|
||||
print(dict(r))
|
||||
PY
|
||||
```
|
||||
|
||||
## 8. 输出要求
|
||||
|
||||
Codex 完成后必须输出:
|
||||
|
||||
```text
|
||||
Status: PASS / FAIL / BLOCKED
|
||||
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
Files Changed: ...
|
||||
Backend Selected: openclaw-channel / ucc-adapter / unavailable
|
||||
Adapter Send Endpoint: detected / added / unsupported
|
||||
Message Outbox Status: sent / queued / failed
|
||||
Audit Status: written / missing
|
||||
Owner WeChat Received: yes / no / not testable
|
||||
Blocked Reason: ...
|
||||
Next Action: ...
|
||||
```
|
||||
|
||||
## 9. 当前期望结果
|
||||
|
||||
最优结果:
|
||||
|
||||
```text
|
||||
backend=ucc-adapter
|
||||
message_outbox.status=sent
|
||||
owner 微信收到 AIOS 测试通知
|
||||
```
|
||||
|
||||
可接受结果:
|
||||
|
||||
```text
|
||||
backend=ucc-adapter
|
||||
message_outbox.status=failed
|
||||
last_error=adapter_send_not_supported
|
||||
```
|
||||
|
||||
不可接受结果:
|
||||
|
||||
```text
|
||||
继续报 Unknown channel: openclaw-weixin
|
||||
假装 sent
|
||||
日志打印 token
|
||||
业务模块直接调用 OpenClaw
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
# OpenClaw 指令:AIOS Autonomous GitOps 静默优化与部署监督
|
||||
|
||||
Directive ID: `OPENCLAW-AIOS-AUTONOMOUS-GITOPS-DEPLOY-20260707`
|
||||
|
||||
Branch: `patch/web-ui-complete-gitops-dashboard-20260706`
|
||||
|
||||
Base: `main`
|
||||
|
||||
Current baseline: `V1R1C4`
|
||||
|
||||
Proposed baseline after approved merge: `V1R1C5`
|
||||
|
||||
## 1. 执行目的
|
||||
|
||||
OpenClaw 负责接管本分支的执行与监督,目标是在不影响现有执行层、不破坏用户使用体验的前提下,完成 AIOS Autonomous GitOps Runtime 的验证、静默优化、部署准备、Owner 通报与合并候选推进。
|
||||
|
||||
本次改动的目标不是直接绕过 main 保护,而是让系统形成:
|
||||
|
||||
- 自动 CI 验证;
|
||||
- Codex 自愈修复;
|
||||
- event bus + daemon 自动执行;
|
||||
- 微信/网页 Owner 审计与操作通道;
|
||||
- V1R1C5 基线提案;
|
||||
- 失败回流自定义 GPT 修复;
|
||||
- 通过后生成 merge candidate。
|
||||
|
||||
## 2. OpenClaw 角色
|
||||
|
||||
OpenClaw 是本任务的执行监督代理,职责如下:
|
||||
|
||||
1. 读取并监督当前分支与 main 的差异。
|
||||
2. 按验收测试执行静态验证、安全验证、策略验证、灰度验证。
|
||||
3. 发现失败后,生成结构化 failure report。
|
||||
4. 将失败项交给自定义 GPT 继续修复同一 patch 分支。
|
||||
5. 修复后重复验证,直到 PASS 或触发 hard block。
|
||||
6. 生成 merge candidate,但不得直接绕过 main 保护。
|
||||
7. 监督 Owner 通知记录:每个 main 版本变更必须有摘要通报与 Web UI 记录。
|
||||
|
||||
## 3. 禁止事项
|
||||
|
||||
OpenClaw 不得执行以下操作:
|
||||
|
||||
- 不得直接 force push;
|
||||
- 不得删除版本历史、tag、audit 记录;
|
||||
- 不得写入真实凭据;
|
||||
- 不得关闭 auth、Gitea 登录保护或 owner sync;
|
||||
- 不得在未通过验收前合并 main;
|
||||
- 不得在未有审批或预授权 runbook 前执行生产 tag、baseline activation 或 rollback execution。
|
||||
|
||||
## 4. 必须执行的命令
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
git checkout patch/web-ui-complete-gitops-dashboard-20260706
|
||||
git diff --stat main...HEAD
|
||||
bash scripts/validate-gitops-platform-ui.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
bash scripts/autonomous-merge-gate.sh patch/web-ui-complete-gitops-dashboard-20260706 main
|
||||
```
|
||||
|
||||
若部署验证需要启动本地 runtime:
|
||||
|
||||
```bash
|
||||
bash deploy/install/install.sh
|
||||
python3 services/aios-autonomous-daemon/daemon.py emit --type patch_created --branch patch/web-ui-complete-gitops-dashboard-20260706 --base main
|
||||
python3 services/aios-autonomous-daemon/daemon.py run --once
|
||||
```
|
||||
|
||||
## 5. 必须验证的直接修改
|
||||
|
||||
OpenClaw 必须检查以下路径:
|
||||
|
||||
- `overlays/user-control-center/portal/gitops-dashboard/`
|
||||
- `services/aios-autonomous-daemon/`
|
||||
- `services/aios-event-ingress/`
|
||||
- `deploy/systemd/`
|
||||
- `deploy/install/`
|
||||
- `config/codex/`
|
||||
- `scripts/`
|
||||
- `tests/acceptance/`
|
||||
- `versions/proposals/V1R1C5-gitops-platform-ui.proposed.json`
|
||||
- `skills/`
|
||||
- `docs/codex/`
|
||||
|
||||
## 6. 必须验证的间接影响
|
||||
|
||||
OpenClaw 必须确认以下内容未被破坏:
|
||||
|
||||
- `/api/base`、`/api/ver`、`/api/diff`、`/api/patch`、`/api/model`、`/api/run`;
|
||||
- `/base`、`/ver`、`/diff`、`/patch`、`/model`、`/run` 登录保护;
|
||||
- Portal 登录入口 `/portal/me/#gitops`;
|
||||
- Gitea require sign-in view;
|
||||
- owner password sync;
|
||||
- active baseline 仍为 `V1R1C4`,未提前激活 `V1R1C5`。
|
||||
|
||||
## 7. Owner 通报要求
|
||||
|
||||
每个 main 版本变更、merge candidate、validation failure、rollback request、approval required 事件都必须形成:
|
||||
|
||||
1. Owner 消息摘要;
|
||||
2. Web UI 审计记录;
|
||||
3. 可追踪 event_id;
|
||||
4. 变更功能摘要;
|
||||
5. 代码量与配置量统计;
|
||||
6. 与上一次主线变化的间隔;
|
||||
7. 测试状态;
|
||||
8. rollback target。
|
||||
|
||||
## 8. OpenClaw 进度监督格式
|
||||
|
||||
OpenClaw 每次监督输出必须包含:
|
||||
|
||||
```text
|
||||
Status: PASS / FAIL / BLOCKED / RUNNING
|
||||
Branch: patch/web-ui-complete-gitops-dashboard-20260706
|
||||
Base: main
|
||||
Current Rev: <rev>
|
||||
Validation: <summary>
|
||||
Gray Test: <summary>
|
||||
Owner Notice: <summary>
|
||||
Merge Candidate: yes/no
|
||||
Blocked Reason: <reason or none>
|
||||
Next Action: <action>
|
||||
```
|
||||
|
||||
## 9. 失败回流规则
|
||||
|
||||
若失败,OpenClaw 必须写入 failure report:
|
||||
|
||||
- 路径:`reports/openclaw/failure-<event_id>.json`
|
||||
- 内容:失败测试 ID、文件路径、失败原因、建议修复、是否阻断合并。
|
||||
|
||||
随后将问题交给自定义 GPT 修改当前 patch 分支,修复后重新执行完整验收。
|
||||
|
||||
## 10. 合并前门禁
|
||||
|
||||
只有全部满足以下条件,OpenClaw 才能标记为 `merge_candidate_ready`:
|
||||
|
||||
- required acceptance tests 全部 PASS;
|
||||
- 无凭据泄露;
|
||||
- 无 direct main write;
|
||||
- 无高风险未审批动作;
|
||||
- 灰度验证 PASS;
|
||||
- Owner 通报与 Web UI 记录正常;
|
||||
- `V1R1C5` baseline proposal 存在且未提前激活。
|
||||
|
||||
## 11. 最终输出
|
||||
|
||||
OpenClaw 最终应生成:
|
||||
|
||||
- `reports/openclaw/final-supervision-report.json`
|
||||
- `reports/codex/merge-candidate.json`
|
||||
- Owner 摘要事件记录;
|
||||
- Web UI audit record;
|
||||
- 若失败则生成 failure report 并阻止合并。
|
||||
@@ -0,0 +1,462 @@
|
||||
/* AIOS GitOps Platform Control Plane
|
||||
* Browser-only runtime with API-first behavior and safe local fallback.
|
||||
* No credentials or secret values are stored here. Model registration accepts secret_ref only.
|
||||
*/
|
||||
const Platform = (() => {
|
||||
const state = {
|
||||
tenant: 'default',
|
||||
role: 'viewer',
|
||||
identity: null,
|
||||
baseline: null,
|
||||
versions: null,
|
||||
runtime: null,
|
||||
patches: null,
|
||||
models: null,
|
||||
approvals: [],
|
||||
audit: [],
|
||||
policy: null,
|
||||
eventSource: null
|
||||
};
|
||||
|
||||
const ENDPOINTS = {
|
||||
identity: 'identity',
|
||||
policy: 'platform/policy',
|
||||
audit: 'audit',
|
||||
approvals: 'approval',
|
||||
approvalRequest: 'approval-request',
|
||||
approvalDecision: 'approval-decision',
|
||||
branch: 'branch',
|
||||
plan: 'patch/plan',
|
||||
base: 'base',
|
||||
versions: 'ver',
|
||||
diff: 'diff',
|
||||
patch: 'patch',
|
||||
model: 'model',
|
||||
priority: 'model-priority',
|
||||
runtime: 'run',
|
||||
rollback: 'rollback-request',
|
||||
tag: 'tag-request',
|
||||
events: 'events/gitops'
|
||||
};
|
||||
|
||||
const DEFAULT_POLICY = {
|
||||
roles: {
|
||||
viewer: ['read:*', 'audit:read'],
|
||||
operator: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create'],
|
||||
admin: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request'],
|
||||
owner: ['read:*', 'audit:*', 'branch:*', 'plan:*', 'approval:*', 'model:*', 'priority:*', 'release:*', 'rollback:*', 'runtime:*']
|
||||
},
|
||||
highRiskActions: ['release:execute', 'rollback:execute', 'runtime:write', 'tag:create', 'main:write'],
|
||||
destructiveActions: ['delete:*', 'force_push', 'purge:*', 'clear:*'],
|
||||
requiredApprovals: { low: 1, medium: 1, high: 2 },
|
||||
tenantIsolation: true,
|
||||
secretHygiene: {
|
||||
allowSecretRefOnly: true,
|
||||
forbiddenFields: ['api_key', 'token', 'password', 'secret', 'private_key', 'bearer']
|
||||
}
|
||||
};
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function qs(sel) { return document.querySelector(sel); }
|
||||
function qsa(sel) { return Array.from(document.querySelectorAll(sel)); }
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
function safeStringify(value) {
|
||||
return JSON.stringify(redact(value), null, 2);
|
||||
}
|
||||
|
||||
function redact(value) {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const k = key.toLowerCase();
|
||||
if (['api_key', 'apikey', 'token', 'password', 'secret', 'private_key', 'authorization', 'bearer'].some(x => k.includes(x))) {
|
||||
out[key] = typeof val === 'string' && val.startsWith('secret_ref://') ? val : '[REDACTED]';
|
||||
} else out[key] = redact(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function put(id, value) {
|
||||
const target = el(id);
|
||||
if (target) target.textContent = typeof value === 'string' ? value : safeStringify(value);
|
||||
}
|
||||
|
||||
async function api(path, method = 'GET', body = undefined, options = {}) {
|
||||
const url = `/api/${path}`;
|
||||
const headers = { 'Content-Type': 'application/json', 'X-AIOS-Tenant': state.tenant };
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) { data = { status: res.status, text: await res.text() }; }
|
||||
if (!res.ok && !options.allowError) {
|
||||
const err = new Error(`API ${method} ${url} failed with ${res.status}`);
|
||||
err.response = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function has(capability) {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
const caps = policy.roles[state.role] || [];
|
||||
return caps.includes('*') || caps.includes(capability) || caps.some(c => c.endsWith(':*') && capability.startsWith(c.slice(0, -1))) || caps.includes('read:*') && capability.startsWith('read:');
|
||||
}
|
||||
|
||||
function guardAction(capability, risk = 'low') {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
if (policy.destructiveActions.some(pattern => matchCapability(pattern, capability))) return { allowed: false, reason: 'destructive_action_blocked' };
|
||||
if (policy.highRiskActions.includes(capability)) return { allowed: false, reason: 'high_risk_requires_approval' };
|
||||
if (!has(capability)) return { allowed: false, reason: 'insufficient_role' };
|
||||
if (risk === 'high' && !['admin', 'owner'].includes(state.role)) return { allowed: false, reason: 'high_risk_admin_required' };
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function matchCapability(pattern, capability) {
|
||||
if (pattern === capability) return true;
|
||||
if (pattern.endsWith('*')) return capability.startsWith(pattern.slice(0, -1));
|
||||
return false;
|
||||
}
|
||||
|
||||
function audit(action, target, status, detail = {}) {
|
||||
const record = redact({
|
||||
ts: now(), tenant: state.tenant, role: state.role, action, target, status, detail
|
||||
});
|
||||
state.audit.unshift(record);
|
||||
state.audit = state.audit.slice(0, 500);
|
||||
localStorage.setItem('aios.gitops.audit', JSON.stringify(state.audit));
|
||||
put('audit-output', record);
|
||||
renderAudit();
|
||||
postAudit(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function postAudit(record) {
|
||||
try { await api(ENDPOINTS.audit, 'POST', record, { allowError: true }); } catch (_) { /* local fallback */ }
|
||||
}
|
||||
|
||||
function loadLocal() {
|
||||
try { state.audit = JSON.parse(localStorage.getItem('aios.gitops.audit') || '[]'); } catch (_) { state.audit = []; }
|
||||
try { state.approvals = JSON.parse(localStorage.getItem('aios.gitops.approvals') || '[]'); } catch (_) { state.approvals = []; }
|
||||
}
|
||||
|
||||
function saveApprovals() {
|
||||
localStorage.setItem('aios.gitops.approvals', JSON.stringify(state.approvals));
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loadLocal();
|
||||
wireTabs();
|
||||
wireForms();
|
||||
await loadIdentity();
|
||||
await loadPolicy();
|
||||
applyRbac();
|
||||
await refreshAll();
|
||||
await refreshApprovals();
|
||||
await refreshAudit();
|
||||
connectEvents();
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const identity = await api(ENDPOINTS.identity, 'GET', undefined, { allowError: true });
|
||||
state.identity = identity && identity.user ? identity : { user: 'unknown', role: identity.role || 'viewer', tenant: identity.tenant || 'default' };
|
||||
state.role = state.identity.role || 'viewer';
|
||||
state.tenant = state.identity.tenant || 'default';
|
||||
} catch (_) {
|
||||
state.identity = { user: 'local-preview', role: 'admin', tenant: 'default', source: 'local_fallback' };
|
||||
state.role = 'admin';
|
||||
state.tenant = 'default';
|
||||
}
|
||||
put('identity-output', state.identity);
|
||||
const roleChip = el('role-chip');
|
||||
if (roleChip) roleChip.textContent = `role: ${state.role}`;
|
||||
}
|
||||
|
||||
async function loadPolicy() {
|
||||
try {
|
||||
const policy = await api(ENDPOINTS.policy, 'GET', undefined, { allowError: true });
|
||||
state.policy = policy && policy.roles ? policy : DEFAULT_POLICY;
|
||||
} catch (_) { state.policy = DEFAULT_POLICY; }
|
||||
put('rbac-output', state.policy);
|
||||
renderPermissionMatrix();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const tasks = [
|
||||
['baseline', ENDPOINTS.base],
|
||||
['versions', ENDPOINTS.versions],
|
||||
['runtime', ENDPOINTS.runtime],
|
||||
['patches', ENDPOINTS.patch],
|
||||
['models', ENDPOINTS.model]
|
||||
];
|
||||
for (const [key, endpoint] of tasks) {
|
||||
try { state[key] = await api(endpoint, 'GET', undefined, { allowError: true }); }
|
||||
catch (e) { state[key] = { error: e.message, response: e.response }; }
|
||||
}
|
||||
put('baseline-output', state.baseline);
|
||||
put('versions-output', state.versions);
|
||||
put('runtime-output', state.runtime);
|
||||
put('patch-summary-output', state.patches);
|
||||
put('model-output', state.models);
|
||||
updateMetrics();
|
||||
renderModelGraph();
|
||||
audit('dashboard.refresh', 'all', 'ok');
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
const runtimeStatus = state.runtime && state.runtime.status || 'unknown';
|
||||
const version = state.baseline && (state.baseline.active_version || state.baseline.version) || '-';
|
||||
const branches = state.patches && Array.isArray(state.patches.branches) ? state.patches.branches.filter(b => b.startsWith('patch/')).length : 0;
|
||||
const pending = state.approvals.filter(a => a.status === 'pending').length;
|
||||
setText('metric-runtime', runtimeStatus);
|
||||
setText('metric-version', version);
|
||||
setText('metric-branches', String(branches));
|
||||
setText('metric-approvals', String(pending));
|
||||
setText('runtime-chip', `runtime: ${runtimeStatus}`);
|
||||
}
|
||||
|
||||
function setText(id, text) { const node = el(id); if (node) node.textContent = text; }
|
||||
|
||||
function applyRbac() {
|
||||
qsa('.require-operator').forEach(btn => btn.disabled = !has('approval:create') && !has('branch:create'));
|
||||
qsa('.require-admin').forEach(btn => btn.disabled = !['admin', 'owner'].includes(state.role));
|
||||
qsa('.guarded').forEach(btn => btn.disabled = state.role !== 'owner');
|
||||
renderPermissionMatrix();
|
||||
}
|
||||
|
||||
function renderPermissionMatrix() {
|
||||
const policy = state.policy || DEFAULT_POLICY;
|
||||
const caps = ['read:*', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request', 'runtime:write'];
|
||||
const rows = Object.keys(policy.roles).map(role => {
|
||||
const cells = caps.map(cap => `<td>${roleHas(role, cap, policy) ? '✅' : '—'}</td>`).join('');
|
||||
return `<tr><th>${escapeHtml(role)}</th>${cells}</tr>`;
|
||||
}).join('');
|
||||
const html = `<table><thead><tr><th>Role</th>${caps.map(c => `<th>${escapeHtml(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
const target = el('permission-matrix'); if (target) target.innerHTML = html;
|
||||
}
|
||||
|
||||
function roleHas(role, cap, policy) {
|
||||
const caps = policy.roles[role] || [];
|
||||
return caps.includes('*') || caps.includes(cap) || caps.some(c => c.endsWith(':*') && cap.startsWith(c.slice(0, -1))) || (caps.includes('read:*') && cap.startsWith('read:'));
|
||||
}
|
||||
|
||||
function renderModelGraph() {
|
||||
const target = el('model-priority-graph');
|
||||
if (!target) return;
|
||||
const base = state.models && Array.isArray(state.models.base) ? state.models.base : [];
|
||||
const custom = state.models && Array.isArray(state.models.custom) ? state.models.custom : [];
|
||||
const priority = state.models && Array.isArray(state.models.priority) ? state.models.priority : [];
|
||||
const models = [...base, ...custom];
|
||||
if (!models.length) { target.innerHTML = '<p class="empty">无模型数据</p>'; return; }
|
||||
const byId = new Map(priority.map(p => [p.model_id || p.id, p]));
|
||||
target.innerHTML = models.map((m, idx) => {
|
||||
const p = byId.get(m.id) || { priority: 100 - idx, enabled: true, fallback_policy: 'next_enabled' };
|
||||
return `<div class="node"><strong>${escapeHtml(m.display_name || m.model || m.id)}</strong><span>${escapeHtml(m.id || '')}</span><em>priority ${escapeHtml(String(p.priority))} · ${p.enabled === false ? 'disabled' : 'enabled'} · ${escapeHtml(p.fallback_policy || 'next_enabled')}</em></div>`;
|
||||
}).join('<div class="edge">↓ fallback</div>');
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvals, 'GET', undefined, { allowError: true });
|
||||
if (remote && Array.isArray(remote.items)) state.approvals = remote.items;
|
||||
else if (Array.isArray(remote)) state.approvals = remote;
|
||||
} catch (_) { /* local approvals already loaded */ }
|
||||
renderApprovals();
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
const target = el('approval-board');
|
||||
if (!target) return;
|
||||
if (!state.approvals.length) { target.innerHTML = '<p class="empty">暂无审批请求</p>'; return; }
|
||||
target.innerHTML = state.approvals.map(item => {
|
||||
const canDecide = has('approval:*') || state.role === 'owner';
|
||||
return `<article class="approval ${escapeHtml(item.risk || 'medium')}">
|
||||
<header><strong>${escapeHtml(item.type)}</strong><span>${escapeHtml(item.status)}</span></header>
|
||||
<p>${escapeHtml(item.target)}</p><small>${escapeHtml(item.reason || '')}</small>
|
||||
<footer>${canDecide && item.status === 'pending' ? `<button data-approve="${escapeHtml(item.id)}">Approve</button><button data-reject="${escapeHtml(item.id)}">Reject</button>` : ''}</footer>
|
||||
</article>`;
|
||||
}).join('');
|
||||
qsa('[data-approve]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.approve, 'approved'));
|
||||
qsa('[data-reject]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.reject, 'rejected'));
|
||||
}
|
||||
|
||||
async function createApproval(payload) {
|
||||
const guard = guardAction('approval:create', payload.risk || 'medium');
|
||||
if (!guard.allowed) return { status: 'blocked', ...guard };
|
||||
const item = {
|
||||
id: `apr-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
tenant: state.tenant,
|
||||
status: 'pending',
|
||||
created_at: now(),
|
||||
created_by: state.identity && state.identity.user || 'unknown',
|
||||
...payload
|
||||
};
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvalRequest, 'POST', item, { allowError: true });
|
||||
if (remote && (remote.id || remote.status)) {
|
||||
audit('approval.create', item.target, 'ok', remote);
|
||||
await refreshApprovals();
|
||||
return remote;
|
||||
}
|
||||
} catch (_) { /* local fallback */ }
|
||||
state.approvals.unshift(item); saveApprovals(); renderApprovals(); updateMetrics();
|
||||
audit('approval.create.local', item.target, 'ok', item);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function decideApproval(id, status) {
|
||||
const guard = guardAction('approval:decide', 'high');
|
||||
if (!guard.allowed) { audit('approval.decide', id, 'blocked', guard); return; }
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.approvalDecision, 'POST', { id, status }, { allowError: true });
|
||||
audit('approval.decide', id, 'ok', remote);
|
||||
} catch (_) {
|
||||
const item = state.approvals.find(a => a.id === id);
|
||||
if (item) { item.status = status; item.decided_at = now(); item.decided_by = state.identity && state.identity.user || 'unknown'; }
|
||||
saveApprovals(); audit('approval.decide.local', id, 'ok', { status });
|
||||
}
|
||||
renderApprovals(); updateMetrics();
|
||||
}
|
||||
|
||||
async function refreshAudit() {
|
||||
try {
|
||||
const remote = await api(ENDPOINTS.audit, 'GET', undefined, { allowError: true });
|
||||
if (remote && Array.isArray(remote.items)) state.audit = remote.items.concat(state.audit).slice(0, 500);
|
||||
} catch (_) { /* local fallback */ }
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const target = el('audit-table'); if (!target) return;
|
||||
const filter = (el('audit-filter') && el('audit-filter').value || '').toLowerCase();
|
||||
const rows = state.audit.filter(r => !filter || JSON.stringify(r).toLowerCase().includes(filter)).slice(0, 100).map(r =>
|
||||
`<tr><td>${escapeHtml(r.ts)}</td><td>${escapeHtml(r.tenant || '')}</td><td>${escapeHtml(r.role || '')}</td><td>${escapeHtml(r.action)}</td><td>${escapeHtml(r.target)}</td><td>${escapeHtml(r.status)}</td></tr>`
|
||||
).join('');
|
||||
target.innerHTML = `<table><thead><tr><th>Time</th><th>Tenant</th><th>Role</th><th>Action</th><th>Target</th><th>Status</th></tr></thead><tbody>${rows || '<tr><td colspan="6">无审计记录</td></tr>'}</tbody></table>`;
|
||||
}
|
||||
|
||||
function parseDiffTree(diffData) {
|
||||
const stdout = diffData && diffData.diff && diffData.diff.stdout || diffData && diffData.stdout || '';
|
||||
const files = [];
|
||||
let current = null;
|
||||
stdout.split('\n').forEach(line => {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const parts = line.split(' ');
|
||||
current = { file: (parts[3] || '').replace(/^b\//, ''), additions: 0, deletions: 0, chunks: 0 };
|
||||
files.push(current);
|
||||
} else if (current) {
|
||||
if (line.startsWith('@@')) current.chunks++;
|
||||
else if (line.startsWith('+') && !line.startsWith('+++')) current.additions++;
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) current.deletions++;
|
||||
}
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function renderDiffTree(diffData) {
|
||||
const target = el('diff-tree'); if (!target) return;
|
||||
const files = parseDiffTree(diffData);
|
||||
if (!files.length) { target.innerHTML = '<p class="empty">未解析到文件级 diff,可能是接口返回过大或为空。</p>'; return; }
|
||||
target.innerHTML = files.map(f => `<div class="diff-file"><strong>${escapeHtml(f.file)}</strong><span>+${f.additions} / -${f.deletions} / ${f.chunks} chunks</span></div>`).join('');
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource) return;
|
||||
try {
|
||||
state.eventSource = new EventSource(`/api/${ENDPOINTS.events}?tenant=${encodeURIComponent(state.tenant)}`);
|
||||
state.eventSource.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
audit('event.gitops', payload.type || 'event', 'ok', payload);
|
||||
if (['approval', 'patch', 'runtime', 'model'].includes(payload.type)) refreshAll();
|
||||
};
|
||||
state.eventSource.onerror = () => { if (state.eventSource) state.eventSource.close(); };
|
||||
} catch (_) { /* server-sent events optional */ }
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
qsa('.tabs button').forEach(button => {
|
||||
button.onclick = () => {
|
||||
qsa('.tabs button').forEach(b => b.classList.remove('active'));
|
||||
qsa('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
const target = el(`tab-${button.dataset.tab}`);
|
||||
if (target) target.classList.add('active');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function wireForms() {
|
||||
const refreshBtn = el('refresh-all'); if (refreshBtn) refreshBtn.onclick = refreshAll;
|
||||
const refreshModel = el('refresh-model'); if (refreshModel) refreshModel.onclick = refreshAll;
|
||||
const refreshApprovalsBtn = el('refresh-approvals'); if (refreshApprovalsBtn) refreshApprovalsBtn.onclick = refreshApprovals;
|
||||
const clearApprovals = el('clear-local-approvals'); if (clearApprovals) clearApprovals.onclick = () => { state.approvals = []; saveApprovals(); renderApprovals(); audit('approval.local.clear', 'localStorage', 'ok'); };
|
||||
const refreshAuditBtn = el('refresh-audit'); if (refreshAuditBtn) refreshAuditBtn.onclick = refreshAudit;
|
||||
const auditFilter = el('audit-filter'); if (auditFilter) auditFilter.oninput = renderAudit;
|
||||
const exportAudit = el('export-audit'); if (exportAudit) exportAudit.onclick = () => downloadJson('aios-gitops-audit.json', state.audit);
|
||||
|
||||
bindForm('approval-form', async f => createApproval({ type: f.get('type'), target: f.get('target'), risk: f.get('risk'), reason: f.get('reason') }), 'approval-output');
|
||||
bindForm('diff-form', async f => {
|
||||
const data = await api(`${ENDPOINTS.diff}?from=${encodeURIComponent(f.get('from'))}&to=${encodeURIComponent(f.get('to'))}`, 'GET', undefined, { allowError: true });
|
||||
renderDiffTree(data); return data;
|
||||
}, 'diff-output');
|
||||
bindForm('branch-form', async f => checkedApi('branch:create', ENDPOINTS.branch, 'POST', { branch: f.get('branch'), base: f.get('base'), message: f.get('message') }), 'branch-output');
|
||||
bindForm('plan-form', async f => checkedApi('plan:create', ENDPOINTS.plan, 'POST', { branch: f.get('branch'), text: f.get('text') }), 'plan-output');
|
||||
bindForm('model-form', async f => {
|
||||
const payload = { provider_name: f.get('provider_name'), base_url: f.get('base_url'), model_name: f.get('model_name'), display_name: f.get('display_name'), api_key_secret_ref: f.get('api_key_secret_ref') };
|
||||
if (!String(payload.api_key_secret_ref || '').startsWith('secret_ref://')) return { status: 'blocked', reason: 'api_key_secret_ref_required' };
|
||||
return checkedApi('model:write', ENDPOINTS.model, 'POST', payload, 'high');
|
||||
}, 'model-register-output');
|
||||
bindForm('priority-form', async f => checkedApi('priority:write', ENDPOINTS.priority, 'PUT', { model_id: f.get('model_id'), priority: Number(f.get('priority')), enabled: f.get('enabled') === 'true', fallback_policy: f.get('fallback_policy') }, 'medium'), 'priority-output');
|
||||
bindForm('rollback-form', async f => checkedApi('rollback:request', ENDPOINTS.rollback, 'POST', { target_version: f.get('target_version'), reason: f.get('reason'), branch: f.get('branch') }, 'high'), 'rollback-output');
|
||||
bindForm('tag-form', async f => checkedApi('release:request', ENDPOINTS.tag, 'POST', { version: f.get('version'), message: f.get('message') }, 'high'), 'tag-output');
|
||||
}
|
||||
|
||||
function bindForm(id, handler, outputId) {
|
||||
const form = el(id); if (!form) return;
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(form);
|
||||
try {
|
||||
const result = await handler(f);
|
||||
put(outputId, result);
|
||||
audit(`${id}.submit`, outputId, result && result.status === 'blocked' ? 'blocked' : 'ok', result);
|
||||
} catch (err) {
|
||||
const detail = { error: err.message, response: err.response };
|
||||
put(outputId, detail);
|
||||
audit(`${id}.submit`, outputId, 'failed', detail);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function checkedApi(capability, endpoint, method, payload, risk = 'low') {
|
||||
const guard = guardAction(capability, risk);
|
||||
if (!guard.allowed) return { status: 'blocked', capability, ...guard };
|
||||
return await api(endpoint, method, payload, { allowError: true });
|
||||
}
|
||||
|
||||
function downloadJson(filename, value) {
|
||||
const blob = new Blob([safeStringify(value)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[ch]));
|
||||
}
|
||||
|
||||
return { bootstrap, refreshAll, state };
|
||||
})();
|
||||
|
||||
window.addEventListener('DOMContentLoaded', Platform.bootstrap);
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "aios-gitops-enterprise-api-contract",
|
||||
"version": "1.0.0",
|
||||
"scope": "portal-gitops-dashboard",
|
||||
"principles": {
|
||||
"all_writes_require_git_commit": true,
|
||||
"direct_main_push_from_ui": false,
|
||||
"secrets_in_git": false,
|
||||
"destructive_operations": "forbidden_or_request_only",
|
||||
"server_side_authz_is_authoritative": true
|
||||
},
|
||||
"read_endpoints": {
|
||||
"baseline": "GET /api/base",
|
||||
"versions": "GET /api/ver",
|
||||
"runtime": "GET /api/run",
|
||||
"patches": "GET /api/patch",
|
||||
"model_registry": "GET /api/model",
|
||||
"diff_raw": "GET /api/diff?from={from}&to={to}",
|
||||
"diff_tree": "GET /api/diff/tree?from={from}&to={to}",
|
||||
"approval_queue": "GET /api/approval/queue",
|
||||
"release_pipeline": "GET /api/release/pipeline",
|
||||
"audit_log": "GET /api/audit/log?limit={limit}",
|
||||
"me": "GET /api/me"
|
||||
},
|
||||
"write_endpoints": {
|
||||
"create_branch": "POST /api/branch",
|
||||
"natural_language_plan": "POST /api/patch/plan",
|
||||
"register_model": "POST /api/model",
|
||||
"set_model_priority": "PUT /api/model-priority",
|
||||
"rollback_request": "POST /api/rollback-request",
|
||||
"tag_request": "POST /api/tag-request",
|
||||
"approval_decision": "POST /api/approval/decision",
|
||||
"release_promotion_request": "POST /api/release/promote-request",
|
||||
"audit_append": "POST /api/audit/log"
|
||||
},
|
||||
"approval_decision_payload": {
|
||||
"request_id": "string",
|
||||
"decision": "approve | reject",
|
||||
"reason": "string",
|
||||
"branch": "patch/approval-..."
|
||||
},
|
||||
"release_promotion_payload": {
|
||||
"from_environment": "dev | staging",
|
||||
"to_environment": "staging | prod",
|
||||
"version": "VxRxCx",
|
||||
"reason": "string",
|
||||
"branch": "patch/release-promote-..."
|
||||
},
|
||||
"response_contract": {
|
||||
"write_success_any_of": ["branch", "commit", "rev", "sync_status"],
|
||||
"auth_failure": {
|
||||
"401": "Bearer token not injected",
|
||||
"403": "Bearer token injected but scope insufficient"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "aios-gitops-enterprise-ui-policy",
|
||||
"version": "1.0.0",
|
||||
"scope": "portal-gitops-dashboard",
|
||||
"client_side_notice": "Client-side RBAC is advisory only. Server-side Actions/API authorization remains authoritative.",
|
||||
"roles": {
|
||||
"viewer": {
|
||||
"label": "Viewer",
|
||||
"permissions": [
|
||||
"read:baseline",
|
||||
"read:versions",
|
||||
"read:diff",
|
||||
"read:patch",
|
||||
"read:model",
|
||||
"read:runtime",
|
||||
"read:audit"
|
||||
]
|
||||
},
|
||||
"developer": {
|
||||
"label": "Developer",
|
||||
"inherits": ["viewer"],
|
||||
"permissions": [
|
||||
"write:branch",
|
||||
"write:plan"
|
||||
]
|
||||
},
|
||||
"admin": {
|
||||
"label": "Admin",
|
||||
"inherits": ["developer"],
|
||||
"permissions": [
|
||||
"write:model",
|
||||
"write:model_priority",
|
||||
"request:rollback",
|
||||
"request:tag"
|
||||
]
|
||||
},
|
||||
"owner": {
|
||||
"label": "Owner",
|
||||
"inherits": ["admin"],
|
||||
"permissions": [
|
||||
"approve:rollback",
|
||||
"approve:tag",
|
||||
"approve:priority",
|
||||
"approve:release"
|
||||
]
|
||||
}
|
||||
},
|
||||
"risk_policy": {
|
||||
"direct_main_write": "disabled_in_ui",
|
||||
"api_keys_in_git": "forbidden",
|
||||
"rollback": "request_only",
|
||||
"tag_create": "request_only_by_default",
|
||||
"runtime_change": "request_only",
|
||||
"baseline_high_risk_change": "request_only",
|
||||
"bulk_delete": "forbidden"
|
||||
},
|
||||
"secret_redaction": {
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"private_key",
|
||||
"credential"
|
||||
],
|
||||
"replacement": "[REDACTED]"
|
||||
},
|
||||
"approval_queues": [
|
||||
"rollback",
|
||||
"tag",
|
||||
"model_priority",
|
||||
"release",
|
||||
"runtime_change"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AIOS Enterprise GitOps Control Plane</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">cnnetmkt.top / AIOS Enterprise</p>
|
||||
<h1>GitOps Enterprise Control Plane</h1>
|
||||
<p class="subtitle">审批流、RBAC、Release Pipeline、Diff Tree、模型优先级、审计查询</p>
|
||||
</div>
|
||||
<div class="statusbar">
|
||||
<span id="role-chip" class="chip">role: loading</span>
|
||||
<span id="runtime-chip" class="chip muted">runtime: loading</span>
|
||||
<button id="refresh-all" class="primary">刷新全部</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="Enterprise GitOps modules">
|
||||
<button data-tab="overview" class="active">总览</button>
|
||||
<button data-tab="rbac">RBAC</button>
|
||||
<button data-tab="approval">审批流</button>
|
||||
<button data-tab="diff">Diff Tree</button>
|
||||
<button data-tab="patch">Patch / Branch</button>
|
||||
<button data-tab="model">模型控制</button>
|
||||
<button data-tab="release">Release Pipeline</button>
|
||||
<button data-tab="audit">审计中心</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="tab-overview" class="tab active">
|
||||
<div class="grid four">
|
||||
<article class="metric"><span>Runtime</span><strong id="metric-runtime">-</strong></article>
|
||||
<article class="metric"><span>Active Version</span><strong id="metric-version">-</strong></article>
|
||||
<article class="metric"><span>Patch Branches</span><strong id="metric-branches">-</strong></article>
|
||||
<article class="metric"><span>Pending Approvals</span><strong id="metric-approvals">-</strong></article>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<article class="card"><h2>Baseline</h2><pre id="baseline-output">loading...</pre></article>
|
||||
<article class="card"><h2>Versions</h2><pre id="versions-output">loading...</pre></article>
|
||||
<article class="card"><h2>Runtime</h2><pre id="runtime-output">loading...</pre></article>
|
||||
<article class="card"><h2>Patch Summary</h2><pre id="patch-summary-output">loading...</pre></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-rbac" class="tab grid two">
|
||||
<article class="card">
|
||||
<h2>当前用户权限</h2>
|
||||
<p class="hint">按钮级权限由 role + capability 共同控制;viewer 只能读。</p>
|
||||
<pre id="identity-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>RBAC 策略</h2>
|
||||
<pre id="rbac-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card span-two">
|
||||
<h2>权限矩阵</h2>
|
||||
<div id="permission-matrix" class="table-wrap">loading...</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-approval" class="tab">
|
||||
<div class="grid two">
|
||||
<article class="card">
|
||||
<h2>Approval Queue</h2>
|
||||
<p class="hint">高风险操作进入审批队列;UI 不直接执行 destructive / production 变更。</p>
|
||||
<div class="toolbar">
|
||||
<button id="refresh-approvals" class="secondary">刷新审批队列</button>
|
||||
<button id="clear-local-approvals" class="secondary guarded">清理本地缓存</button>
|
||||
</div>
|
||||
<div id="approval-board" class="approval-board">loading...</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>创建审批请求</h2>
|
||||
<form id="approval-form" class="stack">
|
||||
<label>类型
|
||||
<select name="type">
|
||||
<option value="merge">merge</option>
|
||||
<option value="release">release</option>
|
||||
<option value="rollback">rollback</option>
|
||||
<option value="model_priority">model_priority</option>
|
||||
<option value="runtime_change">runtime_change</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>目标 <input name="target" placeholder="branch / version / model id" required /></label>
|
||||
<label>风险
|
||||
<select name="risk"><option>low</option><option selected>medium</option><option>high</option></select>
|
||||
</label>
|
||||
<label>说明 <textarea name="reason" rows="5" required></textarea></label>
|
||||
<button class="primary require-operator" type="submit">提交审批请求</button>
|
||||
</form>
|
||||
<pre id="approval-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-diff" class="tab">
|
||||
<article class="card">
|
||||
<h2>File-level Diff Tree</h2>
|
||||
<p class="hint">支持 from/to 参数;会自动按文件聚合 diff,便于 review。</p>
|
||||
<form id="diff-form" class="form-row wrap">
|
||||
<label>From <input name="from" value="HEAD~1" /></label>
|
||||
<label>To <input name="to" value="HEAD" /></label>
|
||||
<button class="primary" type="submit">读取 Diff</button>
|
||||
</form>
|
||||
<div id="diff-tree" class="diff-tree">等待查询...</div>
|
||||
<pre id="diff-output">等待查询...</pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-patch" class="tab grid two">
|
||||
<article class="card">
|
||||
<h2>创建 Patch Branch</h2>
|
||||
<p class="hint">默认从 main 创建非生产 patch 分支;main 写入仅通过明确授权的 GitOps Action。</p>
|
||||
<form id="branch-form" class="stack">
|
||||
<label>Branch <input name="branch" placeholder="patch/my-change" required /></label>
|
||||
<label>Base <input name="base" value="main" /></label>
|
||||
<label>Message <input name="message" value="Create GitOps patch branch" /></label>
|
||||
<button class="primary require-operator" type="submit">创建 Branch</button>
|
||||
</form>
|
||||
<pre id="branch-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>自然语言 GitOps Plan</h2>
|
||||
<p class="hint">仅生成可审计 plan;高风险操作必须审批。</p>
|
||||
<form id="plan-form" class="stack">
|
||||
<label>目标 Branch <input name="branch" value="patch/web-ui-complete-gitops-dashboard-20260706" /></label>
|
||||
<label>Plan Text <textarea name="text" rows="5" placeholder="描述要执行的 GitOps 变更" required></textarea></label>
|
||||
<button class="primary require-operator" type="submit">生成 Plan</button>
|
||||
</form>
|
||||
<pre id="plan-output">等待操作...</pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="tab-model" class="tab">
|
||||
<div class="grid two">
|
||||
<article class="card">
|
||||
<h2>模型注册表</h2>
|
||||
<button id="refresh-model" class="secondary">刷新模型</button>
|
||||
<pre id="model-output">loading...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>模型优先级图</h2>
|
||||
<div id="model-priority-graph" class="graph">loading...</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>注册自定义模型</h2>
|
||||
<p class="hint">仅允许 secret_ref;禁止真实 API key 明文。</p>
|
||||
<form id="model-form" class="stack">
|
||||
<label>Provider <input name="provider_name" placeholder="openai / one-api / compatible" required /></label>
|
||||
<label>Base URL <input name="base_url" placeholder="https://example.com/v1" required /></label>
|
||||
<label>Model Name <input name="model_name" placeholder="model-id" required /></label>
|
||||
<label>Display Name <input name="display_name" placeholder="Human readable name" /></label>
|
||||
<label>API Key Secret Ref <input name="api_key_secret_ref" placeholder="secret_ref://..." required /></label>
|
||||
<button class="primary require-admin" type="submit">注册模型</button>
|
||||
</form>
|
||||
<pre id="model-register-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>模型优先级 / Fallback</h2>
|
||||
<form id="priority-form" class="stack">
|
||||
<label>Model ID <input name="model_id" placeholder="base.gpt-5.5" required /></label>
|
||||
<label>Priority <input name="priority" type="number" value="100" required /></label>
|
||||
<label>Enabled <select name="enabled"><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label>Fallback Policy <input name="fallback_policy" value="next_enabled" /></label>
|
||||
<button class="primary require-admin" type="submit">保存优先级</button>
|
||||
</form>
|
||||
<pre id="priority-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-release" class="tab">
|
||||
<div class="pipeline" id="release-pipeline">
|
||||
<div class="stage">Patch</div><div class="arrow">→</div><div class="stage">Review</div><div class="arrow">→</div><div class="stage">Approval</div><div class="arrow">→</div><div class="stage guarded-stage">Tag Request</div><div class="arrow">→</div><div class="stage guarded-stage">Deploy</div>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<article class="card danger-zone">
|
||||
<h2>Rollback Request</h2>
|
||||
<p class="hint">只创建审批请求;不直接执行回滚。</p>
|
||||
<form id="rollback-form" class="stack">
|
||||
<label>Target Version <input name="target_version" placeholder="V1R1C3" required /></label>
|
||||
<label>Reason <textarea name="reason" rows="4" required></textarea></label>
|
||||
<label>Request Branch <input name="branch" value="patch/rollback-request-web-ui" /></label>
|
||||
<button class="danger require-admin" type="submit">创建 Rollback 请求</button>
|
||||
</form>
|
||||
<pre id="rollback-output">等待操作...</pre>
|
||||
</article>
|
||||
<article class="card danger-zone">
|
||||
<h2>Tag / Version Request</h2>
|
||||
<p class="hint">默认创建 owner/admin approval request;UI 禁用直接执行。</p>
|
||||
<form id="tag-form" class="stack">
|
||||
<label>Version <input name="version" placeholder="V1R1C5" required /></label>
|
||||
<label>Message <input name="message" placeholder="Release request" /></label>
|
||||
<label class="disabled"><input name="execute" type="checkbox" disabled /> 直接执行(禁用)</label>
|
||||
<button class="danger require-admin" type="submit">创建 Tag 请求</button>
|
||||
</form>
|
||||
<pre id="tag-output">等待操作...</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-audit" class="tab">
|
||||
<article class="card">
|
||||
<h2>审计中心</h2>
|
||||
<div class="toolbar">
|
||||
<input id="audit-filter" placeholder="过滤 action / target / status" />
|
||||
<button id="export-audit" class="secondary">导出 JSON</button>
|
||||
<button id="refresh-audit" class="secondary">刷新</button>
|
||||
</div>
|
||||
<div id="audit-table" class="table-wrap">loading...</div>
|
||||
<pre id="audit-output">暂无操作。</pre>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "gitops-dashboard",
|
||||
"version": "1.0.0",
|
||||
"mount": "/portal/me/#gitops-dashboard",
|
||||
"description": "Complete GitOps Web UI module",
|
||||
"features": [
|
||||
"baseline_view",
|
||||
"version_list",
|
||||
"diff_view",
|
||||
"patch_view",
|
||||
"model_registry",
|
||||
"runtime_status"
|
||||
],
|
||||
"api": {
|
||||
"base": "/api/base",
|
||||
"ver": "/api/ver",
|
||||
"diff": "/api/diff",
|
||||
"patch": "/api/patch",
|
||||
"model": "/api/model",
|
||||
"runtime": "/api/run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
body{font-family:Arial,Helvetica,sans-serif;background:#0b0f14;color:#e6edf3;margin:0}
|
||||
header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#111826;border-bottom:1px solid #222}
|
||||
nav button{margin-right:8px;padding:8px 10px;background:#1f2937;color:#e6edf3;border:1px solid #2b3645;cursor:pointer}
|
||||
nav button:hover{background:#2b3645}
|
||||
main{padding:16px}
|
||||
pre{background:#0f172a;padding:12px;border:1px solid #1f2937;overflow:auto;min-height:400px}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"base": "main",
|
||||
"created_by": "gitops-write-bot",
|
||||
"created_at": "2026-07-06T15:33:08Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BRANCH=$1
|
||||
BASE=${2:-main}
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "Usage: autonomous-merge-gate.sh <branch> <base>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[Autonomous Gate] Evaluating: $BRANCH vs $BASE"
|
||||
|
||||
REPORT_DIR="reports/codex"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
# Run validation and capture exit code correctly
|
||||
bash scripts/validate-gitops-platform-ui.sh "$BRANCH" "$BASE"
|
||||
VALIDATION_RC=$?
|
||||
|
||||
if [ $VALIDATION_RC -ne 0 ]; then
|
||||
echo "FAIL: validation failed"
|
||||
exit $VALIDATION_RC
|
||||
fi
|
||||
|
||||
# Secret safety re-check
|
||||
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||
echo "BLOCKED: secret leak"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Create merge candidate artifact
|
||||
cat > "$REPORT_DIR/merge-candidate.json" <<EOF
|
||||
{
|
||||
"branch": "$BRANCH",
|
||||
"base": "$BASE",
|
||||
"status": "merge_candidate",
|
||||
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "PASS: merge candidate ready"
|
||||
exit 0
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enqueue a WeChat notification into WebOps message_outbox.
|
||||
|
||||
Business modules should call this helper or the equivalent WebOps API instead of
|
||||
calling OpenClaw / WeChat adapters directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
MIGRATION = ROOT / "db" / "migrations" / "20260707_message_outbox.sql"
|
||||
|
||||
|
||||
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||
if MIGRATION.exists():
|
||||
con.executescript(MIGRATION.read_text(encoding="utf-8"))
|
||||
else:
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT NOT NULL,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Enqueue a WeChat notification")
|
||||
parser.add_argument("--recipient", "--recipient-user-id", dest="recipient_user_id", required=True)
|
||||
parser.add_argument("--recipient-role", default="owner")
|
||||
parser.add_argument("--message-type", default="alert")
|
||||
parser.add_argument("--priority", default="normal", choices=["critical", "high", "normal", "low"])
|
||||
parser.add_argument("--title", default="AIOS 通知")
|
||||
parser.add_argument("--content", required=True)
|
||||
parser.add_argument("--payload", default="{}")
|
||||
parser.add_argument("--id")
|
||||
args = parser.parse_args()
|
||||
|
||||
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
msg_id = args.id or f"msg_{int(time.time() * 1000)}"
|
||||
|
||||
with sqlite3.connect(DB) as con:
|
||||
ensure_schema(con)
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO message_outbox (
|
||||
id, channel, recipient_user_id, recipient_role, message_type,
|
||||
priority, title, content, payload, status, created_at
|
||||
) VALUES (?, 'wechat', ?, ?, ?, ?, ?, ?, ?, 'queued', datetime('now'))
|
||||
""",
|
||||
(
|
||||
msg_id,
|
||||
args.recipient_user_id,
|
||||
args.recipient_role,
|
||||
args.message_type,
|
||||
args.priority,
|
||||
args.title,
|
||||
args.content,
|
||||
args.payload,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
print(json.dumps({"status": "queued", "id": msg_id, "db": str(DB)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure WebOps message outbox schema.
|
||||
|
||||
This schema is the only supported active outbound notification entrypoint for
|
||||
business modules and external systems. Delivery is performed by the worker.
|
||||
|
||||
The migration is backward-compatible with already-created SQLite tables:
|
||||
columns are added before indexes that reference them, and UNIQUE constraints are
|
||||
created as indexes because SQLite cannot add a UNIQUE column via ALTER TABLE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
|
||||
BASE_TABLES_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_system TEXT,
|
||||
source_event_id TEXT,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_role TEXT,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
notification_prefs TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
INDEX_SQL = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_outbox_idempotency
|
||||
ON message_outbox(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_outbox_dispatch
|
||||
ON message_outbox(channel, status, priority, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||
ON channel_bindings(user_id, channel_type, status, updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_bindings_role_lookup
|
||||
ON channel_bindings(user_role, channel_type, status, updated_at);
|
||||
"""
|
||||
|
||||
COLUMNS = {
|
||||
"message_outbox": {
|
||||
"idempotency_key": "TEXT",
|
||||
"recipient_user_id": "TEXT",
|
||||
"recipient_role": "TEXT",
|
||||
"source_system": "TEXT",
|
||||
"source_event_id": "TEXT",
|
||||
"require_approval": "INTEGER NOT NULL DEFAULT 0",
|
||||
"cancelled_at": "TEXT",
|
||||
},
|
||||
"channel_bindings": {
|
||||
"user_role": "TEXT",
|
||||
"notification_prefs": "TEXT",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def existing_columns(con: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {row[1] for row in con.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def ensure_schema(db_path: Path = DB) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(db_path) as con:
|
||||
con.executescript(BASE_TABLES_SQL)
|
||||
for table, cols in COLUMNS.items():
|
||||
existing = existing_columns(con, table)
|
||||
for name, ddl in cols.items():
|
||||
if name not in existing:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
|
||||
con.executescript(INDEX_SQL)
|
||||
con.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ensure_schema()
|
||||
print(f"schema_ok db={DB}")
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BRANCH=$1
|
||||
BASE=${2:-main}
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "Usage: validate-gitops-platform-ui.sh <branch> <base>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[Codex] Validating branch: $BRANCH against $BASE"
|
||||
|
||||
echo "[1/5] Git diff check"
|
||||
git diff --stat $BASE...$BRANCH
|
||||
|
||||
echo "[2/5] Acceptance test load"
|
||||
if [ ! -f tests/acceptance/gitops-platform-ui.acceptance.json ]; then
|
||||
echo "FAIL: acceptance file missing"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[3/5] Secret scan"
|
||||
if grep -R "BEGIN PRIVATE KEY" -n overlays/ >/dev/null 2>&1; then
|
||||
echo "FAIL: private key detected"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if grep -R "Bearer " -n overlays/ >/dev/null 2>&1; then
|
||||
echo "WARN: bearer token pattern found"
|
||||
fi
|
||||
|
||||
echo "[4/5] UI structural validation"
|
||||
FILES=(
|
||||
overlays/user-control-center/portal/gitops-dashboard/index.html
|
||||
overlays/user-control-center/portal/gitops-dashboard/app.js
|
||||
)
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "FAIL: missing $f"
|
||||
exit 4
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[5/5] Policy validation"
|
||||
grep -R "direct_main_push" -n overlays/user-control-center/portal/gitops-dashboard/ || true
|
||||
|
||||
echo "PASS: Codex validation completed"
|
||||
exit 0
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production WeChat notify worker for AIOS / Xiamayiti.
|
||||
|
||||
Flow:
|
||||
message_outbox -> channel_bindings -> wechat_send_backend -> sent/failed/retry/audit
|
||||
|
||||
Business modules must not call OpenClaw or UCC Adapter directly. They only
|
||||
enqueue messages. The pluggable backend chooses OpenClaw channel or UCC Adapter
|
||||
at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
ROOT = Path(os.environ.get("AIOS_WEBOPS_ROOT", "/data/aios-gitops/workspace"))
|
||||
DB = Path(os.environ.get("AIOS_WEBOPS_DB", str(ROOT / "data" / "webops.sqlite3")))
|
||||
DEFAULT_LIMIT = int(os.environ.get("AIOS_WECHAT_WORKER_LIMIT", "20"))
|
||||
|
||||
PRIORITY_SQL = """
|
||||
CASE priority
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'normal' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def load_module(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if not spec or not spec.loader:
|
||||
raise RuntimeError(f"cannot load module: {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[attr-defined]
|
||||
return mod
|
||||
|
||||
|
||||
def ensure_schema(con: sqlite3.Connection) -> None:
|
||||
schema_path = ROOT / "scripts" / "message_outbox_schema.py"
|
||||
if schema_path.exists():
|
||||
mod = load_module(schema_path, "message_outbox_schema")
|
||||
mod.ensure_schema(DB)
|
||||
return
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT,
|
||||
channel TEXT NOT NULL,
|
||||
recipient_user_id TEXT,
|
||||
recipient_role TEXT,
|
||||
message_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_system TEXT,
|
||||
source_event_id TEXT,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_role TEXT,
|
||||
channel_type TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
notification_prefs TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def audit(con: sqlite3.Connection, action: str, target: str, detail: Dict[str, Any]) -> None:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs(id, actor, action, target, detail, created_at)
|
||||
VALUES(?, 'system', ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(f"audit_{int(time.time() * 1000)}", action, target, json.dumps(detail, ensure_ascii=False)),
|
||||
)
|
||||
|
||||
|
||||
def load_wechat_target(con: sqlite3.Connection, row: sqlite3.Row) -> str:
|
||||
recipient_user_id = row["recipient_user_id"]
|
||||
recipient_role = row["recipient_role"]
|
||||
|
||||
if recipient_user_id:
|
||||
found = con.execute(
|
||||
"""
|
||||
SELECT external_chat_id
|
||||
FROM channel_bindings
|
||||
WHERE user_id = ?
|
||||
AND channel_type = 'wechat'
|
||||
AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipient_user_id,),
|
||||
).fetchone()
|
||||
if found:
|
||||
return found["external_chat_id"]
|
||||
|
||||
if recipient_role:
|
||||
found = con.execute(
|
||||
"""
|
||||
SELECT external_chat_id
|
||||
FROM channel_bindings
|
||||
WHERE (user_role = ? OR user_id = ?)
|
||||
AND channel_type = 'wechat'
|
||||
AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipient_role, recipient_role),
|
||||
).fetchone()
|
||||
if found:
|
||||
return found["external_chat_id"]
|
||||
|
||||
raise RuntimeError(f"wechat binding not found for recipient_user_id={recipient_user_id} role={recipient_role}")
|
||||
|
||||
|
||||
def sanitize_content(row: sqlite3.Row) -> str:
|
||||
content = row["content"] or ""
|
||||
blocked_markers = ["BEGIN PRIVATE KEY", "Bearer ", "PASSWORD=", "TOKEN=", "/data/aios-gitops/runtime/secrets"]
|
||||
for marker in blocked_markers:
|
||||
if marker in content:
|
||||
content = content.replace(marker, "[REDACTED]")
|
||||
title = row["title"] or "AIOS 通知"
|
||||
return f"{title}\n\n{content}"
|
||||
|
||||
|
||||
def send_with_backend(target: str, content: str, row: sqlite3.Row, dry_run: bool = False) -> Dict[str, Any]:
|
||||
backend_path = ROOT / "scripts" / "wechat_send_backend.py"
|
||||
if not backend_path.exists():
|
||||
raise RuntimeError("wechat_send_backend.py not found")
|
||||
backend = load_module(backend_path, "wechat_send_backend")
|
||||
metadata = {}
|
||||
if row["payload"]:
|
||||
try:
|
||||
metadata = json.loads(row["payload"])
|
||||
except Exception:
|
||||
metadata = {"raw_payload": row["payload"][:1000]}
|
||||
return backend.send_message(
|
||||
target=target,
|
||||
content=content,
|
||||
account_id=os.environ.get("AIOS_WECHAT_ACCOUNT_ID", ""),
|
||||
message_id=row["id"],
|
||||
metadata=metadata,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def mark_dispatching(con: sqlite3.Connection, msg_id: str) -> bool:
|
||||
cur = con.execute("UPDATE message_outbox SET status='dispatching' WHERE id=? AND status='queued'", (msg_id,))
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def process_row(con: sqlite3.Connection, row: sqlite3.Row, dry_run: bool = False) -> None:
|
||||
msg_id = row["id"]
|
||||
if int(row["require_approval"] or 0) == 1 and row["message_type"] == "approval_request":
|
||||
con.execute("UPDATE message_outbox SET status='approval_pending' WHERE id=?", (msg_id,))
|
||||
audit(con, "wechat_outbound_approval_pending", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id})
|
||||
return
|
||||
|
||||
if not mark_dispatching(con, msg_id):
|
||||
return
|
||||
|
||||
try:
|
||||
target = load_wechat_target(con, row)
|
||||
content = sanitize_content(row)
|
||||
result = send_with_backend(target, content, row, dry_run=dry_run)
|
||||
ok = bool(result.get("ok"))
|
||||
if not ok:
|
||||
code = result.get("code") or result.get("status") or "send_failed"
|
||||
raise RuntimeError(f"wechat send failed via {result.get('backend')}: {code}; {result.get('error') or result}")
|
||||
status = "sent" if not dry_run else "queued"
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE message_outbox
|
||||
SET status=?,
|
||||
sent_at=CASE WHEN ?='sent' THEN datetime('now') ELSE sent_at END,
|
||||
payload=json_set(COALESCE(payload, '{}'), '$.send_result', ?),
|
||||
last_error=NULL
|
||||
WHERE id=?
|
||||
""",
|
||||
(status, status, json.dumps(result, ensure_ascii=False), msg_id),
|
||||
)
|
||||
audit(con, "wechat_outbound_sent" if not dry_run else "wechat_outbound_dry_run", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "backend": result.get("backend"), "result": result})
|
||||
except Exception as exc:
|
||||
retry_count = int(row["retry_count"] or 0) + 1
|
||||
status = "failed" if retry_count >= 5 else "queued"
|
||||
con.execute(
|
||||
"UPDATE message_outbox SET retry_count=?, status=?, last_error=? WHERE id=?",
|
||||
(retry_count, status, str(exc)[:1000], msg_id),
|
||||
)
|
||||
audit(con, "wechat_outbound_failed", row["recipient_user_id"] or row["recipient_role"] or "unknown", {"message_id": msg_id, "retry_count": retry_count, "error": str(exc)[:1000]})
|
||||
|
||||
|
||||
def run_once(limit: int, dry_run: bool = False) -> Dict[str, Any]:
|
||||
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(DB) as con:
|
||||
con.row_factory = sqlite3.Row
|
||||
ensure_schema(con)
|
||||
rows = con.execute(
|
||||
f"SELECT * FROM message_outbox WHERE channel='wechat' AND status='queued' ORDER BY {PRIORITY_SQL}, created_at LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
process_row(con, row, dry_run=dry_run)
|
||||
con.commit()
|
||||
return {"status": "ok", "processed": len(rows), "db": str(DB), "dry_run": dry_run}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Dispatch message_outbox WeChat notifications through the selected backend")
|
||||
parser.add_argument("--once", action="store_true", help="Run one dispatch pass and exit")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Run backend dry-run if supported and keep messages queued")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument("--interval", type=int, default=int(os.environ.get("AIOS_WECHAT_WORKER_INTERVAL", "10")))
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.once:
|
||||
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
while True:
|
||||
print(json.dumps(run_once(args.limit, dry_run=args.dry_run), ensure_ascii=False), flush=True)
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pluggable WeChat send backend for AIOS / Xiamayiti.
|
||||
|
||||
Selection order:
|
||||
1. AIOS_WECHAT_SEND_BACKEND, if explicitly set;
|
||||
2. UCC adapter backend, when ucc-wechat-qr-adapter is alive;
|
||||
3. OpenClaw channel backend, only when channel discovery finds wechat/weixin;
|
||||
4. unavailable with explicit diagnostics.
|
||||
|
||||
This module must not print runtime secrets and must not hard-code account,
|
||||
target, adapter credentials, or channel names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
|
||||
OPENCLAW_GATEWAY = os.environ.get("AIOS_OPENCLAW_GATEWAY", "http://127.0.0.1:18789")
|
||||
OPENCLAW_AUTH_MODE = os.environ.get("AIOS_OPENCLAW_AUTH_MODE", "")
|
||||
OPENCLAW_AUTH_VALUE = os.environ.get("AIOS_OPENCLAW_AUTH_VALUE", "")
|
||||
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")
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _openclaw_base_args() -> List[str]:
|
||||
args = [OPENCLAW_BIN]
|
||||
if OPENCLAW_GATEWAY:
|
||||
args.extend(["--gateway", OPENCLAW_GATEWAY])
|
||||
if OPENCLAW_AUTH_MODE and OPENCLAW_AUTH_VALUE:
|
||||
if OPENCLAW_AUTH_MODE == "token":
|
||||
args.extend(["--token", OPENCLAW_AUTH_VALUE])
|
||||
elif OPENCLAW_AUTH_MODE == "password":
|
||||
args.extend(["--password", OPENCLAW_AUTH_VALUE])
|
||||
return args
|
||||
|
||||
|
||||
def _run_openclaw(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, text=True, capture_output=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def _json_or_raw(text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return json.loads(text or "{}")
|
||||
except Exception:
|
||||
return {"raw": (text or "")[:1000]}
|
||||
|
||||
|
||||
def discover_openclaw_wechat_channel() -> Optional[str]:
|
||||
if WECHAT_CHANNEL:
|
||||
return WECHAT_CHANNEL
|
||||
cmd = _openclaw_base_args() + ["channels", "list", "--json"]
|
||||
proc = _run_openclaw(cmd, timeout=15)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
data = _json_or_raw(proc.stdout)
|
||||
candidates: List[str] = []
|
||||
|
||||
def walk(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
name = str(value.get("name") or value.get("channel") or value.get("id") or "")
|
||||
typ = str(value.get("type") or value.get("kind") or "")
|
||||
joined = f"{name} {typ}".lower()
|
||||
if ("wechat" in joined or "weixin" in joined) and name:
|
||||
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"}
|
||||
if UCC_ADAPTER_TOKEN:
|
||||
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
|
||||
headers["X-AIOS-Token"] = UCC_ADAPTER_TOKEN
|
||||
return headers
|
||||
|
||||
|
||||
def _unique_paths(paths: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
out = []
|
||||
for path in paths:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _ucc_send_paths() -> List[str]:
|
||||
return _unique_paths([UCC_SEND_PATH, "/api/internal/wechat/send", "/internal/channels/wechat/send"])
|
||||
|
||||
|
||||
def ucc_adapter_alive() -> bool:
|
||||
for path in ("/health", "/api/internal/health", "/api/health", "/status", "/api/internal/wechat/status"):
|
||||
try:
|
||||
req = urllib.request.Request(UCC_ADAPTER_URL.rstrip("/") + path, headers=_auth_headers(), method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def select_backend() -> Dict[str, Any]:
|
||||
explicit = SEND_BACKEND.strip().lower()
|
||||
if explicit:
|
||||
return {"backend": explicit, "reason": "explicit"}
|
||||
|
||||
if ucc_adapter_alive():
|
||||
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
|
||||
|
||||
channel = discover_openclaw_wechat_channel()
|
||||
if channel:
|
||||
return {"backend": "openclaw-channel", "channel": channel, "reason": "openclaw_channel_discovered"}
|
||||
|
||||
return {
|
||||
"backend": "unavailable",
|
||||
"reason": "wechat outbound backend unavailable: ucc adapter not reachable; openclaw channel not registered",
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
channel = WECHAT_CHANNEL or discover_openclaw_wechat_channel()
|
||||
if not channel:
|
||||
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_wechat_channel_not_registered"})
|
||||
account = account_id or WECHAT_ACCOUNT_ID
|
||||
if not account:
|
||||
return BackendResult(False, "openclaw-channel", "failed", {"code": "openclaw_account_not_configured"})
|
||||
|
||||
cmd = _openclaw_base_args() + [
|
||||
"message",
|
||||
"send",
|
||||
"--channel",
|
||||
channel,
|
||||
"--account",
|
||||
account,
|
||||
"--target",
|
||||
target,
|
||||
"-m",
|
||||
content,
|
||||
]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
cmd.append("--json")
|
||||
|
||||
proc = _run_openclaw(cmd)
|
||||
if proc.returncode != 0:
|
||||
return BackendResult(
|
||||
False,
|
||||
"openclaw-channel",
|
||||
"failed",
|
||||
{"code": "openclaw_send_failed", "error": (proc.stderr or proc.stdout)[:1000], "channel": channel},
|
||||
)
|
||||
return BackendResult(
|
||||
True,
|
||||
"openclaw-channel",
|
||||
"sent" if not dry_run else "dry_run",
|
||||
{"channel": channel, "result": _json_or_raw(proc.stdout)},
|
||||
)
|
||||
|
||||
|
||||
def _post_ucc(path: str, payload: Dict[str, Any]) -> tuple[int, Dict[str, Any]]:
|
||||
url = UCC_ADAPTER_URL.rstrip("/") + path
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=_auth_headers(), method="POST")
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status, _json_or_raw(resp.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool) -> bool:
|
||||
status = str(data.get("status") or data.get("delivery_status") or "").lower()
|
||||
if dry_run:
|
||||
return bool(data.get("ok") or data.get("accepted")) and status in {"dry_run", "sent", "accepted", "ok"}
|
||||
return bool(data.get("ok")) and status == "sent"
|
||||
|
||||
|
||||
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:
|
||||
payload = {
|
||||
"target": target,
|
||||
"content": content,
|
||||
"account_id": account_id or WECHAT_ACCOUNT_ID,
|
||||
"message_id": message_id,
|
||||
"metadata": metadata or {},
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
last_error: Dict[str, Any] = {}
|
||||
for path in _ucc_send_paths():
|
||||
try:
|
||||
http_status, data = _post_ucc(path, payload)
|
||||
ok = _ucc_result_is_ok(data, dry_run)
|
||||
status = str(data.get("status") or ("sent" if ok and not dry_run else "dry_run" if ok else "failed"))
|
||||
code = str(data.get("code") or status)
|
||||
return BackendResult(
|
||||
ok,
|
||||
"ucc-adapter",
|
||||
status if ok else "failed",
|
||||
{"code": code, "http_status": http_status, "endpoint": path, "result": data},
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
data = _json_or_raw(exc.read().decode("utf-8", errors="replace"))
|
||||
last_error = {"code": data.get("code") or "ucc_adapter_http_error", "http_status": exc.code, "endpoint": path, "error": data}
|
||||
if exc.code in {404, 405}:
|
||||
continue
|
||||
return BackendResult(False, "ucc-adapter", "failed", last_error)
|
||||
except Exception as exc:
|
||||
last_error = {"code": "ucc_adapter_unavailable", "endpoint": path, "error": str(exc)[:1000]}
|
||||
continue
|
||||
return BackendResult(False, "ucc-adapter", "failed", last_error or {"code": "ucc_adapter_unavailable"})
|
||||
|
||||
|
||||
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()
|
||||
backend = selected.get("backend")
|
||||
if backend == "ucc-adapter":
|
||||
return send_ucc_adapter(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||
if backend == "openclaw-channel":
|
||||
return send_openclaw_channel(target, content, account_id, message_id, metadata, dry_run).to_dict()
|
||||
if backend == "unavailable":
|
||||
return BackendResult(False, "unavailable", "failed", {"code": "wechat_outbound_backend_unavailable", "error": selected.get("reason")}).to_dict()
|
||||
return BackendResult(False, backend or "unknown", "failed", {"code": "unsupported_backend", "backend": backend}).to_dict()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(select_backend(), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Autonomous GitOps Daemon.
|
||||
|
||||
Low-impact sidecar daemon for:
|
||||
- reading GitOps events from a file-backed event bus;
|
||||
- running safe validation/self-healing commands;
|
||||
- sending owner notifications through a runtime-injected WeChat/WeCom webhook;
|
||||
- creating audit records and request artifacts instead of bypassing production gates.
|
||||
|
||||
No credentials are stored in this file. Runtime secrets must be injected by the host
|
||||
through environment variables or secret-mounted files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||
CONFIG_PATH = ROOT / "config" / "codex" / "self-optimizing-orchestrator.json"
|
||||
PROD_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-production-policy.json"
|
||||
MERGE_POLICY_PATH = ROOT / "config" / "codex" / "autonomous-merge-policy.json"
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", str(ROOT / "runtime" / "gitops" / "events")))
|
||||
AUDIT_DIR = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(ROOT / "runtime" / "gitops" / "audit")))
|
||||
REQUEST_DIR = pathlib.Path(os.environ.get("AIOS_REQUEST_DIR", str(ROOT / "runtime" / "gitops" / "requests")))
|
||||
REPORT_DIR = pathlib.Path(os.environ.get("AIOS_REPORT_DIR", str(ROOT / "reports" / "codex")))
|
||||
PREAPPROVAL_DIR = pathlib.Path(os.environ.get("AIOS_PREAPPROVAL_DIR", str(ROOT / "approvals" / "preapproved")))
|
||||
|
||||
SAFE_AUTONOMOUS_EVENTS = {
|
||||
"git_push",
|
||||
"patch_created",
|
||||
"validation_failed",
|
||||
"validation_passed",
|
||||
"merge_candidate_created",
|
||||
"runtime_health_degraded",
|
||||
}
|
||||
|
||||
OWNER_NOTIFY_EVENTS = {
|
||||
"main_version_changed",
|
||||
"baseline_changed",
|
||||
"merge_candidate_created",
|
||||
"validation_failed",
|
||||
"rollback_request_created",
|
||||
"approval_required",
|
||||
"daily_digest",
|
||||
}
|
||||
|
||||
PRODUCTION_ACTIONS = {
|
||||
"merge_to_main",
|
||||
"create_release_tag",
|
||||
"activate_new_baseline",
|
||||
"production_runtime_change",
|
||||
"production_rollback_execution",
|
||||
}
|
||||
|
||||
ALWAYS_BLOCKED = {
|
||||
"force_push",
|
||||
"purge_baseline",
|
||||
"delete_version_history",
|
||||
"delete_audit_records",
|
||||
"commit_real_secret",
|
||||
"disable_auth_or_gitea_protection",
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: pathlib.Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def event_id(event: Dict[str, Any]) -> str:
|
||||
raw = json.dumps(event, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def audit(action: str, status: str, detail: Dict[str, Any]) -> Dict[str, Any]:
|
||||
record = {
|
||||
"ts": utc_now(),
|
||||
"action": action,
|
||||
"status": status,
|
||||
"detail": redact(detail),
|
||||
}
|
||||
write_json(AUDIT_DIR / f"{record['ts'].replace(':', '-')}-{action}.json", record)
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
return record
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [redact(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
out: Dict[str, Any] = {}
|
||||
for key, val in value.items():
|
||||
lowered = key.lower()
|
||||
sensitive = any(word in lowered for word in ["api_key", "apikey", "password", "secret", "private_key", "credential", "webhook"])
|
||||
if sensitive and not (isinstance(val, str) and val.startswith("secret_ref://")):
|
||||
out[key] = "[REDACTED]"
|
||||
else:
|
||||
out[key] = redact(val)
|
||||
return out
|
||||
return value
|
||||
|
||||
|
||||
def run_cmd(cmd: List[str], timeout: int = 300) -> Tuple[int, str, str]:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(ROOT),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def read_event_files(limit: int = 100) -> List[pathlib.Path]:
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(EVENT_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)[:limit]
|
||||
|
||||
|
||||
def archive_event(path: pathlib.Path, status: str) -> None:
|
||||
target = EVENT_DIR / status / path.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(target))
|
||||
|
||||
|
||||
def get_webhook_url() -> Optional[str]:
|
||||
# Runtime-only injection. Do not commit real values.
|
||||
return os.environ.get("AIOS_OWNER_WECHAT_WEBHOOK_URL") or os.environ.get("AIOS_OWNER_WECOM_WEBHOOK_URL")
|
||||
|
||||
|
||||
def sign_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||
secret = os.environ.get("AIOS_OWNER_ACTION_SIGNING_SECRET")
|
||||
if not secret:
|
||||
return None
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def send_owner_wechat(event: Dict[str, Any], summary: Dict[str, Any]) -> Dict[str, Any]:
|
||||
url = get_webhook_url()
|
||||
if not url:
|
||||
return {"status": "skipped", "reason": "wechat_webhook_not_configured"}
|
||||
|
||||
content = format_owner_message(event, summary)
|
||||
body = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": content},
|
||||
}
|
||||
signature = sign_payload(body)
|
||||
if signature:
|
||||
body["aios_signature"] = signature
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = resp.read().decode("utf-8", errors="replace")
|
||||
return {"status": "sent", "http_status": resp.status, "response": data[:500]}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
|
||||
|
||||
def format_owner_message(event: Dict[str, Any], summary: Dict[str, Any]) -> str:
|
||||
title = event.get("type", "gitops_event")
|
||||
branch = summary.get("branch") or event.get("branch") or "-"
|
||||
from_version = summary.get("from_version") or event.get("from_version") or "-"
|
||||
to_version = summary.get("to_version") or event.get("to_version") or "-"
|
||||
changed = summary.get("changed_features") or event.get("changed_features") or []
|
||||
if isinstance(changed, list):
|
||||
changed_text = ", ".join(str(x) for x in changed[:8]) or "-"
|
||||
else:
|
||||
changed_text = str(changed)
|
||||
return "\n".join([
|
||||
f"### AIOS GitOps 通报:{title}",
|
||||
f"- Branch: `{branch}`",
|
||||
f"- Version: `{from_version}` → `{to_version}`",
|
||||
f"- Code Δ: +{summary.get('code_lines_added', 0)} / -{summary.get('code_lines_deleted', 0)}",
|
||||
f"- Config Δ: +{summary.get('config_lines_added', 0)} / -{summary.get('config_lines_deleted', 0)}",
|
||||
f"- Elapsed: {summary.get('elapsed_since_previous_main_change', '-')}",
|
||||
f"- Risk: {summary.get('risk', event.get('risk', 'unknown'))}",
|
||||
f"- Test: {summary.get('test_status', '-')}",
|
||||
f"- Rollback: `{summary.get('rollback_target', 'V1R1C4')}`",
|
||||
f"- Changed: {changed_text}",
|
||||
f"- Web UI: {summary.get('web_ui_audit_url', '/portal/me/#gitops-audit')}",
|
||||
])
|
||||
|
||||
|
||||
def diff_stats(base: str, branch: str) -> Dict[str, int]:
|
||||
code_add = code_del = config_add = config_del = 0
|
||||
rc, stdout, _ = run_cmd(["git", "diff", "--numstat", f"{base}...{branch}"], timeout=60)
|
||||
if rc != 0:
|
||||
return {"code_lines_added": 0, "code_lines_deleted": 0, "config_lines_added": 0, "config_lines_deleted": 0}
|
||||
config_ext = {".json", ".yaml", ".yml", ".toml", ".ini", ".conf", ".service"}
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3 or parts[0] == "-" or parts[1] == "-":
|
||||
continue
|
||||
add, delete, path = int(parts[0]), int(parts[1]), parts[2]
|
||||
if pathlib.Path(path).suffix in config_ext or path.startswith("config/"):
|
||||
config_add += add
|
||||
config_del += delete
|
||||
else:
|
||||
code_add += add
|
||||
code_del += delete
|
||||
return {
|
||||
"code_lines_added": code_add,
|
||||
"code_lines_deleted": code_del,
|
||||
"config_lines_added": config_add,
|
||||
"config_lines_deleted": config_del,
|
||||
}
|
||||
|
||||
|
||||
def build_summary(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
stats = diff_stats(base, branch)
|
||||
return {
|
||||
"event_id": event.get("id") or event_id(event),
|
||||
"branch": branch,
|
||||
"from_rev": event.get("from_rev", base),
|
||||
"to_rev": event.get("to_rev", branch),
|
||||
"from_version": event.get("from_version", "V1R1C4"),
|
||||
"to_version": event.get("to_version", "V1R1C5"),
|
||||
"elapsed_since_previous_main_change": event.get("elapsed_since_previous_main_change", "unknown"),
|
||||
"changed_features": event.get("changed_features", ["GitOps autonomous orchestration", "Owner notifications", "event bus", "safe daemon"]),
|
||||
"test_status": event.get("test_status", "pending"),
|
||||
"risk": event.get("risk", "high"),
|
||||
"approval_status": event.get("approval_status", "required"),
|
||||
"rollback_target": event.get("rollback_target", "V1R1C4"),
|
||||
"web_ui_audit_url": event.get("web_ui_audit_url", "/portal/me/#gitops-audit"),
|
||||
**stats,
|
||||
}
|
||||
|
||||
|
||||
def has_preapproval(action: str, event: Dict[str, Any]) -> bool:
|
||||
target_version = event.get("to_version") or "V1R1C5"
|
||||
rollback_target = event.get("rollback_target") or "V1R1C4"
|
||||
now_dt = _dt.datetime.now(_dt.timezone.utc)
|
||||
for path in PREAPPROVAL_DIR.glob("*.json"):
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
allowed = doc.get("allowed_actions", [])
|
||||
if action not in allowed:
|
||||
continue
|
||||
if doc.get("target_version") != target_version:
|
||||
continue
|
||||
if doc.get("rollback_target") != rollback_target:
|
||||
continue
|
||||
if doc.get("approved_by_role") not in ["owner", "admin"]:
|
||||
continue
|
||||
expires_at = doc.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
exp = _dt.datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
if exp > now_dt:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_request(event: Dict[str, Any], reason: str) -> Dict[str, Any]:
|
||||
req = {
|
||||
"id": f"req-{utc_now().replace(':', '-')}-{event_id(event)}",
|
||||
"created_at": utc_now(),
|
||||
"event": redact(event),
|
||||
"reason": reason,
|
||||
"status": "pending_owner_review",
|
||||
"web_ui": "/portal/me/#gitops-owner-actions",
|
||||
}
|
||||
write_json(REQUEST_DIR / f"{req['id']}.json", req)
|
||||
return req
|
||||
|
||||
|
||||
def run_validation(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
script = ROOT / "scripts" / "validate-gitops-platform-ui.sh"
|
||||
if not script.exists():
|
||||
return {"status": "blocked", "reason": "validation_script_missing"}
|
||||
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(REPORT_DIR / "validation-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/validation-daemon.log"}
|
||||
|
||||
|
||||
def run_merge_gate(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
branch = event.get("branch") or "patch/web-ui-complete-gitops-dashboard-20260706"
|
||||
base = event.get("base") or "main"
|
||||
script = ROOT / "scripts" / "autonomous-merge-gate.sh"
|
||||
if not script.exists():
|
||||
return {"status": "blocked", "reason": "merge_gate_script_missing"}
|
||||
rc, out, err = run_cmd(["bash", str(script), branch, base], timeout=600)
|
||||
(REPORT_DIR / "merge-gate-daemon.log").write_text(out + "\n" + err, encoding="utf-8")
|
||||
return {"status": "passed" if rc == 0 else "failed", "exit_code": rc, "log": "reports/codex/merge-gate-daemon.log"}
|
||||
|
||||
|
||||
def handle_event(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
event_type = event.get("type", "unknown")
|
||||
action = event.get("action", event_type)
|
||||
|
||||
if action in ALWAYS_BLOCKED:
|
||||
req = create_request(event, f"Blocked permanently by autonomous production policy: {action}")
|
||||
audit("event.blocked", "blocked", {"event": event, "request": req})
|
||||
return {"status": "blocked", "request": req}
|
||||
|
||||
summary = build_summary(event)
|
||||
|
||||
if event_type in OWNER_NOTIFY_EVENTS:
|
||||
notify_result = send_owner_wechat(event, summary)
|
||||
audit("owner.notify", notify_result.get("status", "unknown"), {"event": event, "summary": summary, "result": notify_result})
|
||||
|
||||
if event_type in {"git_push", "patch_created", "validation_failed"}:
|
||||
validation = run_validation(event)
|
||||
audit("codex.validation", validation["status"], {"event": event, "validation": validation})
|
||||
if validation["status"] != "passed":
|
||||
req = create_request(event, "Validation failed; GPT repair loop required.")
|
||||
return {"status": "validation_failed", "request": req, "validation": validation}
|
||||
gate = run_merge_gate(event)
|
||||
audit("codex.merge_gate", gate["status"], {"event": event, "gate": gate})
|
||||
return {"status": "merge_candidate" if gate["status"] == "passed" else "gate_failed", "validation": validation, "gate": gate}
|
||||
|
||||
if action in PRODUCTION_ACTIONS:
|
||||
if has_preapproval(action, event):
|
||||
# This daemon deliberately records intent. The actual protected write must be
|
||||
# performed by configured GitOps Actions with server-side authorization.
|
||||
req = create_request(event, f"Preapproval found for {action}; execute through protected GitOps Action.")
|
||||
audit("production.preapproved", "request_created", {"event": event, "request": req})
|
||||
return {"status": "preapproved_request_created", "request": req}
|
||||
req = create_request(event, f"Production action requires preapproved runbook: {action}")
|
||||
audit("production.approval_required", "pending", {"event": event, "request": req})
|
||||
return {"status": "approval_required", "request": req}
|
||||
|
||||
audit("event.record", "ok", {"event": event, "summary": summary})
|
||||
return {"status": "recorded", "summary": summary}
|
||||
|
||||
|
||||
def process_once() -> int:
|
||||
count = 0
|
||||
for path in read_event_files():
|
||||
try:
|
||||
event = json.loads(path.read_text(encoding="utf-8"))
|
||||
result = handle_event(event)
|
||||
archive_event(path, "done")
|
||||
count += 1
|
||||
audit("event.processed", "ok", {"path": str(path), "result": result})
|
||||
except Exception as exc: # noqa: BLE001 - daemon must not crash on single event
|
||||
archive_event(path, "failed")
|
||||
audit("event.processed", "failed", {"path": str(path), "error": str(exc)})
|
||||
return count
|
||||
|
||||
|
||||
def emit_event(args: argparse.Namespace) -> None:
|
||||
event = {
|
||||
"id": args.id or f"evt-{utc_now().replace(':', '-')}",
|
||||
"type": args.type,
|
||||
"action": args.action or args.type,
|
||||
"branch": args.branch,
|
||||
"base": args.base,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"risk": args.risk,
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
write_json(EVENT_DIR / f"{event['id']}.json", event)
|
||||
print(json.dumps(redact(event), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def main(argv: Optional[Iterable[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Autonomous GitOps Daemon")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
run = sub.add_parser("run")
|
||||
run.add_argument("--interval", type=int, default=30)
|
||||
run.add_argument("--once", action="store_true")
|
||||
|
||||
emit = sub.add_parser("emit")
|
||||
emit.add_argument("--id")
|
||||
emit.add_argument("--type", required=True)
|
||||
emit.add_argument("--action")
|
||||
emit.add_argument("--branch", default="patch/web-ui-complete-gitops-dashboard-20260706")
|
||||
emit.add_argument("--base", default="main")
|
||||
emit.add_argument("--from-version", default="V1R1C4")
|
||||
emit.add_argument("--to-version", default="V1R1C5")
|
||||
emit.add_argument("--risk", default="high")
|
||||
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
if args.cmd == "emit":
|
||||
emit_event(args)
|
||||
return 0
|
||||
|
||||
if args.cmd == "run":
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
REQUEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if args.once:
|
||||
return 0 if process_once() >= 0 else 1
|
||||
audit("daemon.start", "ok", {"event_dir": str(EVENT_DIR)})
|
||||
while True:
|
||||
process_once()
|
||||
time.sleep(args.interval)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Multi-Agent Consensus Controller
|
||||
|
||||
This service aggregates decisions from OpenClaw, Codex, and ChatGPT agents
|
||||
and computes a bounded consensus decision for GitOps operations.
|
||||
|
||||
It does NOT directly modify production state. It only produces decision artifacts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", ".")).resolve()
|
||||
CONSENSUS_DIR = ROOT / "reports" / "consensus"
|
||||
AGENT_DIRS = {
|
||||
"openclaw": ROOT / "reports" / "openclaw",
|
||||
"codex": ROOT / "reports" / "codex",
|
||||
"chatgpt": ROOT / "reports" / "chatgpt",
|
||||
}
|
||||
|
||||
CONSENSUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_latest(path: pathlib.Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
files = sorted(path.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not files:
|
||||
return {}
|
||||
return json.loads(files[0].read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def compute_consensus(reports: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
weights = {"openclaw": 2, "codex": 2, "chatgpt": 1}
|
||||
|
||||
score = 0
|
||||
for agent, report in reports.items():
|
||||
status = report.get("status", "unknown")
|
||||
if status in ("PASS", "approved", "merge_candidate"):
|
||||
score += weights.get(agent, 1)
|
||||
elif status in ("FAIL", "blocked"):
|
||||
score -= weights.get(agent, 1)
|
||||
|
||||
if score >= 4:
|
||||
decision = "approved"
|
||||
elif score >= 2:
|
||||
decision = "conditional"
|
||||
else:
|
||||
decision = "rejected"
|
||||
|
||||
return {
|
||||
"decision": decision,
|
||||
"score": score,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
reports = {
|
||||
"openclaw": load_latest(AGENT_DIRS["openclaw"]),
|
||||
"codex": load_latest(AGENT_DIRS["codex"]),
|
||||
"chatgpt": load_latest(AGENT_DIRS["chatgpt"]),
|
||||
}
|
||||
|
||||
result = compute_consensus(reports)
|
||||
|
||||
out_path = CONSENSUS_DIR / "decision.json"
|
||||
out_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
|
||||
print("CONSENSUS:", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS Event Ingress Server
|
||||
|
||||
Receives GitOps events via HTTP and writes them into the local event bus directory.
|
||||
No authentication secrets are embedded; optional signature validation can be enabled via env.
|
||||
"""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", "runtime/gitops/events"))
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length).decode('utf-8')
|
||||
try:
|
||||
event = json.loads(body)
|
||||
except Exception:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
event_id = event.get("id", f"evt-{int(time.time())}")
|
||||
path = EVENT_DIR / f"{event_id}.json"
|
||||
path.write_text(json.dumps(event, indent=2), encoding='utf-8')
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("AIOS_INGRESS_PORT", "8787"))
|
||||
server = HTTPServer(("0.0.0.0", port), Handler)
|
||||
print(f"[AIOS] Event ingress running on {port}")
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""V1R1 Personal WeChat Endpoint Discovery
|
||||
|
||||
Helps locate active V1R1 personal WeChat runtime endpoints on the host.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
CANDIDATES = [8788, 8789, 8790, 8088, 8000]
|
||||
|
||||
print("[AIOS] Scanning for V1R1 WeChat endpoints...")
|
||||
|
||||
for port in CANDIDATES:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", "-lc", f"ss -ltnp | grep {port}"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.stdout:
|
||||
print(f"[FOUND] Port {port}: {result.stdout.strip()}")
|
||||
else:
|
||||
print(f"[MISS] Port {port}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Port {port}: {e}")
|
||||
|
||||
print("[AIOS] Discovery complete. Please map discovered endpoint to AIOS_V1R1_WECHAT_OUTBOUND_URL")
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS V1R1 Personal WeChat Outbound Worker.
|
||||
|
||||
This worker is the bridge between the Xiamayiti self-evolution loop and the
|
||||
already-deployed V1R1 personal WeChat capability.
|
||||
|
||||
Design rules:
|
||||
- Do not embed credentials.
|
||||
- Do not access runtime/secrets directly.
|
||||
- Read owner-notification outbox JSON files.
|
||||
- Deliver to a runtime-configured V1R1 notification endpoint, or try local V1R1 candidates.
|
||||
- Write delivery status for audit/Web UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
ROOT = pathlib.Path(os.environ.get("AIOS_REPO_ROOT", "/data/aios-gitops/workspace"))
|
||||
BASE = pathlib.Path(os.environ.get("AIOS_RUNTIME_ROOT", "/data/aios-gitops/runtime"))
|
||||
OUTBOX = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_OUTBOX", str(BASE / "integrations" / "v1r1-personal-wechat" / "outbox")))
|
||||
SENT = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_SENT", str(BASE / "integrations" / "v1r1-personal-wechat" / "sent")))
|
||||
FAILED = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_FAILED", str(BASE / "integrations" / "v1r1-personal-wechat" / "failed")))
|
||||
STATUS = pathlib.Path(os.environ.get("AIOS_V1R1_WECHAT_STATUS", str(BASE / "integrations" / "v1r1-personal-wechat" / "status")))
|
||||
AUDIT = pathlib.Path(os.environ.get("AIOS_AUDIT_DIR", str(BASE / "audit")))
|
||||
INTERVAL = int(os.environ.get("AIOS_V1R1_WECHAT_INTERVAL", "10"))
|
||||
|
||||
# Runtime-configured endpoint. This can point to the existing V1R1 personal-WeChat module.
|
||||
PRIMARY_ENDPOINT = os.environ.get("AIOS_V1R1_WECHAT_OUTBOUND_URL", "")
|
||||
|
||||
LOCAL_CANDIDATES = [
|
||||
"http://127.0.0.1:8788/api/v1r1/personal-wechat/send",
|
||||
"http://127.0.0.1:8788/api/v1r1/notification/owner",
|
||||
"http://127.0.0.1:8789/api/owner/wechat/send",
|
||||
"http://127.0.0.1:8790/owner/notify",
|
||||
]
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def write_json(path: pathlib.Path, value: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def audit(action: str, status: str, detail: Dict[str, Any]) -> None:
|
||||
record = {"ts": now(), "action": action, "status": status, "detail": detail}
|
||||
safe_name = record["ts"].replace(":", "-") + f"-{action}.json"
|
||||
write_json(AUDIT / safe_name, record)
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def normalize_message(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = item.get("title") or "AIOS GitOps 通报"
|
||||
content = item.get("content") or item.get("message") or json.dumps(item, ensure_ascii=False)
|
||||
priority = item.get("priority") or "normal"
|
||||
return {
|
||||
"event_id": item.get("event_id") or item.get("id") or f"evt-{int(time.time())}",
|
||||
"channel": "personal_wechat",
|
||||
"priority": priority,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": item.get("source") or "aios-self-evolution",
|
||||
"branch": item.get("branch"),
|
||||
"from_version": item.get("from_version"),
|
||||
"to_version": item.get("to_version"),
|
||||
"rollback_target": item.get("rollback_target") or "V1R1C4",
|
||||
"web_ui_audit_url": item.get("web_ui_audit_url") or "/portal/me/#gitops-audit",
|
||||
"created_at": item.get("created_at") or now(),
|
||||
}
|
||||
|
||||
|
||||
def endpoints() -> List[str]:
|
||||
values: List[str] = []
|
||||
if PRIMARY_ENDPOINT:
|
||||
values.append(PRIMARY_ENDPOINT)
|
||||
values.extend(LOCAL_CANDIDATES)
|
||||
# Preserve order but remove duplicates.
|
||||
result: List[str] = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if value and value not in seen:
|
||||
result.append(value)
|
||||
seen.add(value)
|
||||
return result
|
||||
|
||||
|
||||
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
text = resp.read().decode("utf-8", errors="replace")[:500]
|
||||
return {"ok": 200 <= resp.status < 300, "status": resp.status, "response": text, "url": url}
|
||||
|
||||
|
||||
def deliver(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
attempts = []
|
||||
for url in endpoints():
|
||||
try:
|
||||
result = post_json(url, message)
|
||||
attempts.append(result)
|
||||
if result.get("ok"):
|
||||
return {"status": "sent", "endpoint": url, "attempts": attempts}
|
||||
except urllib.error.URLError as exc:
|
||||
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
attempts.append({"ok": False, "url": url, "error": str(exc)})
|
||||
return {"status": "failed", "attempts": attempts, "reason": "no_endpoint_accepted_message"}
|
||||
|
||||
|
||||
def process_file(path: pathlib.Path) -> None:
|
||||
try:
|
||||
item = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_target = FAILED / path.name
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(failed_target))
|
||||
audit("v1r1.wechat.parse", "failed", {"path": str(path), "error": str(exc)})
|
||||
return
|
||||
|
||||
message = normalize_message(item)
|
||||
result = deliver(message)
|
||||
status_record = {"ts": now(), "file": path.name, "message": message, "delivery": result}
|
||||
write_json(STATUS / (path.stem + ".status.json"), status_record)
|
||||
|
||||
if result.get("status") == "sent":
|
||||
SENT.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(SENT / path.name))
|
||||
audit("v1r1.wechat.deliver", "sent", status_record)
|
||||
else:
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(FAILED / path.name))
|
||||
audit("v1r1.wechat.deliver", "failed", status_record)
|
||||
|
||||
|
||||
def process_once() -> int:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for path in sorted(OUTBOX.glob("*.json"), key=lambda p: p.stat().st_mtime):
|
||||
process_file(path)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def emit_startup_notice() -> pathlib.Path:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
path = OUTBOX / f"startup-{int(time.time())}.json"
|
||||
write_json(path, {
|
||||
"event_id": f"startup-{int(time.time())}",
|
||||
"type": "owner_notification",
|
||||
"channel": "personal_wechat",
|
||||
"priority": "high",
|
||||
"title": "AIOS 自我进化系统已启动",
|
||||
"content": "AIOS 虾马一体 self-evolution loop 已进入运行态。daemon、event bus、GitOps 写链路已恢复;后续将通过 V1R1 个人微信模块与 Web UI 审计同步进展。",
|
||||
"source": "aios-v1r1-wechat-outbound-worker",
|
||||
"branch": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"from_version": "V1R1C4",
|
||||
"to_version": "V1R1C5",
|
||||
"rollback_target": "V1R1C4",
|
||||
"web_ui_audit_url": "/portal/me/#gitops-audit",
|
||||
"created_at": now()
|
||||
})
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="AIOS V1R1 personal WeChat outbound worker")
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--emit-startup-notice", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
SENT.mkdir(parents=True, exist_ok=True)
|
||||
FAILED.mkdir(parents=True, exist_ok=True)
|
||||
STATUS.mkdir(parents=True, exist_ok=True)
|
||||
AUDIT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.emit_startup_notice:
|
||||
path = emit_startup_notice()
|
||||
audit("v1r1.wechat.outbox", "created", {"path": str(path)})
|
||||
|
||||
if args.once:
|
||||
process_once()
|
||||
return 0
|
||||
|
||||
audit("v1r1.wechat.worker.start", "ok", {"outbox": str(OUTBOX), "has_primary_endpoint": bool(PRIMARY_ENDPOINT)})
|
||||
while True:
|
||||
process_once()
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
# Skill: GitOps Platform Validation
|
||||
|
||||
## Name
|
||||
gitops-platform-validation
|
||||
|
||||
## Version
|
||||
1.0.0
|
||||
|
||||
## Purpose
|
||||
Provide reusable Codex execution behavior for validating GitOps platform UI branches and enabling self-healing CI loops.
|
||||
|
||||
## Trigger
|
||||
- branch matches: patch/*
|
||||
- contains: gitops-dashboard
|
||||
|
||||
## Actions
|
||||
1. Run validation script
|
||||
```bash
|
||||
bash scripts/validate-gitops-platform-ui.sh <branch> main
|
||||
```
|
||||
|
||||
2. If FAIL:
|
||||
- generate failure report
|
||||
- invoke GPT repair loop
|
||||
- re-run validation
|
||||
|
||||
3. If PASS:
|
||||
- run autonomous merge gate
|
||||
```bash
|
||||
bash scripts/autonomous-merge-gate.sh <branch> main
|
||||
```
|
||||
|
||||
4. If merge candidate created:
|
||||
- propose V1R1C5 baseline update
|
||||
|
||||
## Constraints
|
||||
- no secrets in logs
|
||||
- no direct main write
|
||||
- destructive operations forbidden
|
||||
- all merges must pass policy gate
|
||||
|
||||
## Self-Healing Behavior
|
||||
- max iterations: 5
|
||||
- stop on:
|
||||
- PASS
|
||||
- security failure
|
||||
- manual override
|
||||
|
||||
## Output
|
||||
- validation report
|
||||
- merge candidate artifact
|
||||
- optional baseline proposal
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"suite": "gitops-platform-ui",
|
||||
"version": "1.0.0",
|
||||
"branch_under_test": "patch/web-ui-complete-gitops-dashboard-20260706",
|
||||
"base_branch": "main",
|
||||
"purpose": "Validate AIOS GitOps Platform Control Plane UI before merge to main and before V1R1C5 baseline proposal.",
|
||||
"required_result": "PASS",
|
||||
"tests": [
|
||||
{
|
||||
"id": "STATIC-001",
|
||||
"group": "static_files",
|
||||
"required": true,
|
||||
"description": "Platform UI files exist.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/styles.css",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/manifest.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "STATIC-002",
|
||||
"group": "static_files",
|
||||
"required": true,
|
||||
"description": "Enterprise tabs exist in HTML.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||
"tab-overview",
|
||||
"tab-rbac",
|
||||
"tab-approval",
|
||||
"tab-diff",
|
||||
"tab-patch",
|
||||
"tab-model",
|
||||
"tab-release",
|
||||
"tab-audit"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RUNTIME-001",
|
||||
"group": "frontend_runtime",
|
||||
"required": true,
|
||||
"description": "Platform runtime bootstraps on DOMContentLoaded.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"Platform.bootstrap",
|
||||
"DOMContentLoaded",
|
||||
"guardAction",
|
||||
"createApproval",
|
||||
"renderDiffTree",
|
||||
"audit(" ,
|
||||
"redact("
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SEC-001",
|
||||
"group": "security",
|
||||
"required": true,
|
||||
"description": "No real credentials are committed.",
|
||||
"assert": {
|
||||
"secret_scan": {
|
||||
"paths": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard",
|
||||
"docs/codex",
|
||||
"tests/acceptance",
|
||||
"scripts",
|
||||
"skills"
|
||||
],
|
||||
"allow_patterns": [
|
||||
"secret_ref://",
|
||||
"[REDACTED]",
|
||||
"api_key_secret_ref",
|
||||
"forbiddenFields",
|
||||
"secret_redaction",
|
||||
"No credentials"
|
||||
],
|
||||
"deny_patterns": [
|
||||
"Bearer [A-Za-z0-9._-]{20,}",
|
||||
"sk-[A-Za-z0-9]{20,}",
|
||||
"ghp_[A-Za-z0-9]{20,}",
|
||||
"AKIA[0-9A-Z]{16}",
|
||||
"BEGIN PRIVATE KEY",
|
||||
"BEGIN RSA PRIVATE KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SEC-002",
|
||||
"group": "security",
|
||||
"required": true,
|
||||
"description": "Model registration requires secret_ref and never raw api_key.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"api_key_secret_ref",
|
||||
"startsWith('secret_ref://')",
|
||||
"api_key_secret_ref_required"
|
||||
]
|
||||
},
|
||||
"file_not_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html": [
|
||||
"name=\"api_key\"",
|
||||
"name=\"token\"",
|
||||
"name=\"password\""
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "POLICY-001",
|
||||
"group": "gitops_policy",
|
||||
"required": true,
|
||||
"description": "Direct main writes and destructive actions are disabled or blocked.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js": [
|
||||
"main:write",
|
||||
"destructive_action_blocked",
|
||||
"high_risk_requires_approval"
|
||||
],
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-ui-policy.json": [
|
||||
"direct_main_write",
|
||||
"disabled_in_ui",
|
||||
"bulk_delete",
|
||||
"forbidden"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "API-001",
|
||||
"group": "api_contract",
|
||||
"required": true,
|
||||
"description": "Read/write endpoint contract remains compatible with existing GitOps APIs.",
|
||||
"assert": {
|
||||
"file_contains": {
|
||||
"overlays/user-control-center/portal/gitops-dashboard/gitops-enterprise-api.contract.json": [
|
||||
"GET /api/base",
|
||||
"GET /api/ver",
|
||||
"GET /api/run",
|
||||
"GET /api/patch",
|
||||
"GET /api/model",
|
||||
"POST /api/branch",
|
||||
"POST /api/patch/plan",
|
||||
"POST /api/model",
|
||||
"PUT /api/model-priority",
|
||||
"POST /api/rollback-request",
|
||||
"POST /api/tag-request"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "REGRESSION-001",
|
||||
"group": "indirect_regression",
|
||||
"required": true,
|
||||
"description": "This branch must not alter auth, gitea owner sync, baseline version, or destructive history files.",
|
||||
"assert": {
|
||||
"diff_not_touch_paths": [
|
||||
"config/auth/",
|
||||
"config/casdoor/",
|
||||
"config/gitea/",
|
||||
"versions/V1R1C4.json",
|
||||
"runtime/gitops/current.json"
|
||||
],
|
||||
"diff_not_contain": [
|
||||
"active_version\": \"V1R1C5",
|
||||
"force push",
|
||||
"delete tag",
|
||||
"purge baseline",
|
||||
"clear audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BASELINE-001",
|
||||
"group": "baseline_proposal",
|
||||
"required": true,
|
||||
"description": "A V1R1C5 proposal exists but is not activated before merge.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json"
|
||||
],
|
||||
"file_contains": {
|
||||
"versions/proposals/V1R1C5-gitops-platform-ui.proposed.json": [
|
||||
"V1R1C5",
|
||||
"V1R1C4",
|
||||
"approval_required"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SKILL-001",
|
||||
"group": "auto_loaded_skill",
|
||||
"required": true,
|
||||
"description": "Codex/GPT reusable skill exists and references the validation workflow.",
|
||||
"assert": {
|
||||
"files_exist": [
|
||||
"skills/gitops-platform-validation/SKILL.md"
|
||||
],
|
||||
"file_contains": {
|
||||
"skills/gitops-platform-validation/SKILL.md": [
|
||||
"validate-gitops-platform-ui.sh",
|
||||
"gitops-platform-ui.acceptance.json",
|
||||
"self-healing"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"merge_gate": {
|
||||
"allow_merge_to_main_when": [
|
||||
"all required tests PASS",
|
||||
"gray deployment PASS",
|
||||
"no secret leak",
|
||||
"no direct main write",
|
||||
"V1R1C5 proposal reviewed",
|
||||
"owner/admin approval recorded"
|
||||
],
|
||||
"block_merge_when": [
|
||||
"any required test FAIL",
|
||||
"any required test BLOCKED without explicit owner override",
|
||||
"secret leak detected",
|
||||
"destructive operation detected",
|
||||
"baseline active version changed before merge"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "V1R1C5",
|
||||
"status": "proposed",
|
||||
"activation": "approval_required",
|
||||
"base_version": "V1R1C4",
|
||||
"rollback_target": "V1R1C4",
|
||||
"purpose": "Promote AIOS GitOps Platform Control Plane with autonomous CI, self-healing validation, bounded production policy, event bus, daemon, owner notification, Web UI audit and Codex validation skills.",
|
||||
"risk": "high",
|
||||
"approval": {
|
||||
"required": true,
|
||||
"roles": ["owner", "admin"],
|
||||
"reason": "Introduces autonomous orchestration and production-adjacent daemon. Main merge, tag creation and baseline activation remain gated."
|
||||
},
|
||||
"scope": {
|
||||
"direct_changes": [
|
||||
"overlays/user-control-center/portal/gitops-dashboard",
|
||||
"services/aios-autonomous-daemon",
|
||||
"config/codex",
|
||||
"scripts",
|
||||
"tests/acceptance",
|
||||
"skills",
|
||||
"docs/codex",
|
||||
"docs/ops",
|
||||
"deploy/systemd"
|
||||
],
|
||||
"indirect_impact": [
|
||||
"Portal GitOps UI visibility",
|
||||
"Owner notification workflow",
|
||||
"Codex validation workflow",
|
||||
"Self-healing patch loop",
|
||||
"Web UI audit trail"
|
||||
]
|
||||
},
|
||||
"non_goals": [
|
||||
"No direct main push",
|
||||
"No committed secrets",
|
||||
"No destructive history deletion",
|
||||
"No production rollback execution without approval or preapproved runbook"
|
||||
],
|
||||
"validation": {
|
||||
"acceptance_suite": "tests/acceptance/gitops-platform-ui.acceptance.json",
|
||||
"script": "scripts/validate-gitops-platform-ui.sh",
|
||||
"merge_gate": "scripts/autonomous-merge-gate.sh",
|
||||
"gray_validation": "scripts/gray-validate-gitops-platform-ui.sh"
|
||||
},
|
||||
"owner_notification": {
|
||||
"required_on_main_version_change": true,
|
||||
"channels": ["wechat_or_wecom", "web_ui_audit"],
|
||||
"webhook_secret_ref": "secret_ref://aios/notifications/owner-wechat-webhook"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user