diff --git a/.gitea/workflows/mcp-external-artifact-mirror.yaml b/.gitea/workflows/mcp-external-artifact-mirror.yaml index 4017c8f30..1fa6be8da 100644 --- a/.gitea/workflows/mcp-external-artifact-mirror.yaml +++ b/.gitea/workflows/mcp-external-artifact-mirror.yaml @@ -3,7 +3,7 @@ # This Gitea-only lane accepts executable bytes from exact npm registry URLs in # committed policy, verifies them, mirrors a scratch OCI data bundle to Harbor, # independently reads the digest back without starting a container, then writes -# the two receipts to Gitea main with a normal non-force push. It never installs +# the two receipts to a Gitea audit branch with a normal non-force push. It never installs # or starts the MCP, opens a browser, writes external content to RAG, or changes a # production route. @@ -26,12 +26,13 @@ on: concurrency: group: awoooi-mcp-external-artifact-mirror - cancel-in-progress: false + cancel-in-progress: true env: GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git HARBOR_REGISTRY: registry.wooo.work POLICY_PATH: config/mcp/playwright-mcp-artifact-policy.json + RECEIPT_BRANCH: mcp-artifact-receipts CONTROLLER_RECEIPT_PATH: docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-controller.snapshot.json VERIFIER_RECEIPT_PATH: docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-verifier.snapshot.json @@ -185,7 +186,7 @@ jobs: print("production_route_changed=false") PY - - name: Commit verified receipts to Gitea main + - name: Commit verified receipts to Gitea audit branch env: CD_PUSH_TOKEN: ${{ secrets.CD_PUSH_TOKEN }} run: | @@ -230,14 +231,18 @@ jobs: git remote add gitea "${GITEA_SOURCE_URL}" for attempt in 1 2 3; do - git fetch --no-tags --depth=100 gitea main - if ! git merge --no-edit gitea/main; then - git merge --abort || true - echo "BLOCKER receipt_writeback_merge_conflict" - exit 1 + if git ls-remote --exit-code --heads gitea \ + "refs/heads/${RECEIPT_BRANCH}" >/dev/null 2>&1; then + git fetch --no-tags --depth=100 gitea "${RECEIPT_BRANCH}" + if ! git merge --no-edit FETCH_HEAD; then + git merge --abort || true + echo "BLOCKER receipt_writeback_merge_conflict" + exit 1 + fi fi - if git push gitea HEAD:main; then - echo "receipt_writeback=verified_normal_push" + if git push gitea "HEAD:refs/heads/${RECEIPT_BRANCH}"; then + echo "receipt_writeback=verified_audit_branch_normal_push" + echo "receipt_branch=${RECEIPT_BRANCH}" echo "receipt_commit_sha=$(git rev-parse HEAD)" exit 0 fi diff --git a/apps/api/src/jobs/asset_capability_reconciliation_job.py b/apps/api/src/jobs/asset_capability_reconciliation_job.py index 8073fbed3..c13545927 100644 --- a/apps/api/src/jobs/asset_capability_reconciliation_job.py +++ b/apps/api/src/jobs/asset_capability_reconciliation_job.py @@ -34,7 +34,12 @@ logger = structlog.get_logger(__name__) _FIRST_DELAY_SECONDS = 90 _LOOP_BACKOFF_SECONDS = 30 * 60 _MAX_RECONCILIATION_CANDIDATES = 20_000 -_RETRYABLE_RESULT_STATUSES = {"blocked_safe_no_write", "degraded_no_write"} +_RECONCILIATION_LIVE_READBACK_TIMEOUT_SECONDS = 30.0 +_RETRYABLE_RESULT_STATUSES = { + "blocked_safe_no_write", + "degraded_cache_refresh", + "degraded_no_write", +} _DAILY_TRIGGER_HOUR_TAIPEI = 3 _DAILY_TRIGGER_MINUTE_TAIPEI = 30 _TAIPEI = ZoneInfo("Asia/Taipei") @@ -232,6 +237,7 @@ async def reconcile_once( started = time.monotonic() matrix = await build_asset_capability_matrix_with_live_readback( project_id=project_id, + timeout_seconds=_RECONCILIATION_LIVE_READBACK_TIMEOUT_SECONDS, include_live_only_assets=True, ) if matrix.get("live_readback_status") != "ready": @@ -264,6 +270,24 @@ async def reconcile_once( candidates, project_id=project_id, ) + public_matrix = await build_asset_capability_matrix_with_live_readback( + project_id=project_id, + timeout_seconds=_RECONCILIATION_LIVE_READBACK_TIMEOUT_SECONDS, + include_live_only_assets=False, + bypass_public_cache=True, + ) + public_cache_refreshed = bool( + public_matrix.get("live_readback_status") == "ready" + and public_matrix.get("closed") is True + ) + receipt["public_cache_refreshed"] = public_cache_refreshed + if not public_cache_refreshed: + receipt["status"] = "degraded_cache_refresh" + logger.warning( + "asset_capability_reconciliation_public_cache_refresh_degraded", + live_readback_status=public_matrix.get("live_readback_status"), + closed=public_matrix.get("closed"), + ) receipt["duration_ms"] = int((time.monotonic() - started) * 1000) logger.info( "asset_capability_reconciliation_completed", diff --git a/apps/api/src/services/ai_automation_asset_capability_matrix.py b/apps/api/src/services/ai_automation_asset_capability_matrix.py index 2f35dba01..30ab3ac40 100644 --- a/apps/api/src/services/ai_automation_asset_capability_matrix.py +++ b/apps/api/src/services/ai_automation_asset_capability_matrix.py @@ -1314,6 +1314,7 @@ async def build_asset_capability_matrix_with_live_readback( repo_root: Path | None = None, timeout_seconds: float = LIVE_READBACK_TIMEOUT_SECONDS, include_live_only_assets: bool = False, + bypass_public_cache: bool = False, ) -> dict[str, Any]: """Build a bounded matrix from live DB truth, failing closed to declared scope.""" @@ -1324,7 +1325,7 @@ async def build_asset_capability_matrix_with_live_readback( bounded_timeout_seconds = max(0.001, float(timeout_seconds)) use_public_cache = repo_root is None and not include_live_only_assets - if use_public_cache: + if use_public_cache and not bypass_public_cache: cached_payload = await _read_public_matrix_cache( project_id, namespace=_PUBLIC_MATRIX_FRESH_CACHE_NAMESPACE, diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index 3b0232322..e85e5fded 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -798,7 +798,7 @@ _AI_AUTOMATION_PROGRAM_WORK_ITEMS: list[dict[str, Any]] = [ "problem": "Gitea runner 仍會把 actions/checkout 等 action 從 github.com 抓入執行環境,GitHub freeze 尚未覆蓋 CI action resolution。", "professional_practice": "self-hosted action registry、immutable digest pinning、SBOM/provenance、network egress verification", "asset_scope_ids": ["products", "tools", "packages", "observability_and_security"], - "observed_awoooi_gitea_workflow_file_count": 13, + "observed_awoooi_gitea_workflow_file_count": 14, "observed_awoooi_external_action_reference_count": 18, "acceptance": [ "所有產品的 Gitea workflow action 只由受控 Gitea mirror 或 repo-owned immutable action 供應", diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e891da881..9f88ac96b 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -7434,6 +7434,206 @@ def _build_awooop_status_chain( } +def _run_error_envelope(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if not value: + return {} + try: + parsed = json.loads(str(value)) + except (TypeError, ValueError, json.JSONDecodeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _terminal_timestamp(value: Any) -> str | None: + if isinstance(value, datetime): + return value.isoformat() + if value is None: + return None + rendered = str(value).strip() + return rendered or None + + +def _apply_run_terminal_truth_precedence( + *, + run: AwoooPRunState, + status_chain: dict[str, Any] | None, +) -> dict[str, Any]: + """Keep a failed run receipt from being painted green by incident history.""" + + chain = dict(status_chain or {}) + if ( + str(getattr(run, "state", "")) != "failed" + or str(getattr(run, "error_code", "")) + != "E-AGENT99-OUTCOME-TIMEOUT" + ): + return chain + + envelope = _run_error_envelope(getattr(run, "error_detail", None)) + if envelope.get("outcome_timeout_no_write_terminal") is not True: + return chain + + verifier = ( + envelope.get("verifier") + if isinstance(envelope.get("verifier"), dict) + else {} + ) + terminal_receipt = ( + verifier.get("receipt") + if isinstance(verifier.get("receipt"), dict) + else {} + ) + safe_next_action = str( + envelope.get("safe_next_action") + or "await_new_source_recurrence_after_agent99_relay_recovery" + ) + historical_incident_context = { + "source": chain.get("source"), + "source_id": chain.get("source_id"), + "incident_ids": chain.get("incident_ids") or [], + "current_stage": chain.get("current_stage"), + "stage_status": chain.get("stage_status"), + "verdict": chain.get("verdict"), + "repair_state": chain.get("repair_state"), + "verification": chain.get("verification"), + "operator_outcome": chain.get("operator_outcome"), + "evidence": chain.get("evidence"), + "writes": chain.get("writes"), + } + run_terminal_precedence = { + "active": True, + "reason": "higher_priority_run_terminal_receipt", + "run_id": str(getattr(run, "run_id", "")), + "run_state": "failed", + "error_code": "E-AGENT99-OUTCOME-TIMEOUT", + "outcome_timeout_no_write_terminal": True, + "timeout_at": ( + terminal_receipt.get("timeout_at") + or _terminal_timestamp(getattr(run, "timeout_at", None)) + ), + "terminal_at": ( + terminal_receipt.get("terminal_at") + or _terminal_timestamp(getattr(run, "completed_at", None)) + ), + "terminalizer_runtime_write_performed": bool( + envelope.get("terminalizer_runtime_write_performed") is True + ), + "outcome_runtime_write_status": str( + envelope.get("outcome_runtime_write_status") + or "unknown_without_authenticated_outcome" + ), + "transport_replayed": bool(envelope.get("transport_replayed") is True), + "incident_resolution_authorized": bool( + envelope.get("incident_resolution_authorized") is True + ), + "runtime_closure_verified": bool( + envelope.get("runtime_closure_verified") is True + ), + "safe_next_action": safe_next_action, + } + + return { + **chain, + "source": "run_terminal_receipt>historical_incident_context", + "current_stage": "outcome_verifier_failed", + "stage_status": "failed", + "verdict": "no_runtime_closure_authenticated_outcome_timeout", + "repair_state": "failed_no_write_terminal", + "verification": "failed_authenticated_outcome_timeout", + "needs_human": False, + "next_step": safe_next_action, + "operator_outcome": { + "schema_version": "operator_outcome_run_terminal_v1", + "state": "failed_no_write_terminal", + "severity": "degraded", + "summary_zh": ( + "Agent99 驗證結果逾時;本 Run 已失敗收斂," + "未宣稱 runtime 修復或事件結案。" + ), + "needs_human": False, + "human_action_required": False, + "human_action_reason": None, + "next_action": safe_next_action, + "execution_result": { + "approval_status": "not_applicable", + "completion_status": "failed_no_write_terminal", + "command_status": "not_replayed", + "repair_status": "not_verified", + "failure_status": "authenticated_outcome_timeout", + "terminal": True, + "summary_zh": ( + "未取得 authenticated outcome;terminalizer 未執行" + " target runtime write、未重送 transport、未關閉 incident。" + ), + }, + "notification": { + "mode": "no_notification", + "channels": [], + "telegram": "not_sent", + "awooop": "readback_only", + }, + }, + "automation_handoff": None, + "blockers": ["authenticated_outcome_timeout_no_write"], + "evidence": { + "auto_repair_records": 0, + "operation_records": 0, + "mcp_gateway_total": 0, + "knowledge_entries": 0, + "remediation_total": 0, + "remediation_state": "run_terminal_receipt", + "latest_route": "--", + "latest_mode": None, + "latest_at": run_terminal_precedence["terminal_at"], + "latest_preview": "authenticated_outcome_timeout_no_write", + "ansible_dry_run_only": False, + }, + "writes": {"incident": False, "auto_repair": False}, + "mcp": { + "gateway": { + "total": 0, + "success": 0, + "failed": 0, + "blocked": 0, + "first_class_total": 0, + "legacy_bridge_total": 0, + "policy_enforced_total": 0, + "stage": "not_applicable_run_terminal", + "stage_status": "failed", + }, + "legacy": {"total": 0, "success": 0, "failed": 0}, + "top_tools": [], + }, + "execution": { + "operation_total": 0, + "latest_operation_type": "outcome_timeout_no_write_terminal", + "latest_status": "failed", + "latest_actor": "agent99_controlled_dispatch_reconciler", + "latest_action": "record_no_write_terminal", + "latest_executor": "none", + "playbook_ids": [], + "playbook_paths": [], + "ansible": { + "considered": False, + "record_total": 0, + "candidate_count": 0, + "not_used_reason": "run_terminal_no_runtime_write", + "check_mode_total": 0, + "apply_total": 0, + "rollback_total": 0, + "applied": False, + "controlled_apply": False, + }, + }, + "repair_candidate_promotion": ( + _empty_repair_candidate_promotion_projection() + ), + "run_terminal_precedence": run_terminal_precedence, + "historical_incident_context": historical_incident_context, + } + + def _empty_repair_candidate_promotion_projection() -> dict[str, Any]: return { "schema_version": "repair_candidate_promotion_projection_v1", @@ -8254,6 +8454,10 @@ async def get_run_detail( project_id=run.project_id, remediation_history=remediation_history, ) + awooop_status_chain = _apply_run_terminal_truth_precedence( + run=run, + status_chain=awooop_status_chain, + ) timeline: list[dict[str, Any]] = [ _timeline_item( diff --git a/apps/api/tests/test_ai_automation_asset_capability_matrix.py b/apps/api/tests/test_ai_automation_asset_capability_matrix.py index ad2680394..ac817d4c5 100644 --- a/apps/api/tests/test_ai_automation_asset_capability_matrix.py +++ b/apps/api/tests/test_ai_automation_asset_capability_matrix.py @@ -389,6 +389,91 @@ def test_reconciliation_persistence_batches_candidate_queries_and_inserts() -> N assert "AND output ->> 'closed' = 'true'" in source +def test_reconcile_once_uses_bounded_worker_live_readback_timeout( + monkeypatch: Any, +) -> None: + captured: dict[str, object] = {} + + async def _fake_live_readback(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"live_readback_status": "degraded"} + + monkeypatch.setattr( + reconciliation_job, + "build_asset_capability_matrix_with_live_readback", + _fake_live_readback, + ) + + result = asyncio.run( + reconciliation_job.reconcile_once( + triggered_by="controlled_replay", + project_id="awoooi", + ) + ) + + assert captured == { + "project_id": "awoooi", + "timeout_seconds": 30.0, + "include_live_only_assets": True, + } + assert result["status"] == "degraded_no_write" + + +def test_reconcile_once_publishes_closed_public_matrix_cache( + monkeypatch: Any, +) -> None: + live_readback_calls: list[dict[str, object]] = [] + + async def _fake_live_readback(**kwargs: object) -> dict[str, object]: + live_readback_calls.append(dict(kwargs)) + if kwargs["include_live_only_assets"] is True: + return {"live_readback_status": "ready"} + return {"live_readback_status": "ready", "closed": True} + + async def _fake_persist( + _matrix: object, + _candidates: object, + *, + project_id: str, + ) -> dict[str, object]: + assert project_id == "awoooi" + return {"closed": True, "candidate_count": 0} + + monkeypatch.setattr( + reconciliation_job, + "build_asset_capability_matrix_with_live_readback", + _fake_live_readback, + ) + monkeypatch.setattr( + reconciliation_job, + "build_reconciliation_candidates", + lambda _matrix: [], + ) + monkeypatch.setattr(reconciliation_job, "_persist_reconciliation", _fake_persist) + + result = asyncio.run( + reconciliation_job.reconcile_once( + triggered_by="controlled_replay", + project_id="awoooi", + ) + ) + + assert live_readback_calls == [ + { + "project_id": "awoooi", + "timeout_seconds": 30.0, + "include_live_only_assets": True, + }, + { + "project_id": "awoooi", + "timeout_seconds": 30.0, + "include_live_only_assets": False, + "bypass_public_cache": True, + }, + ] + assert result["public_cache_refreshed"] is True + + def test_reconciliation_loop_retries_degraded_result_before_daily_wait( monkeypatch: Any, ) -> None: @@ -678,6 +763,37 @@ def test_public_matrix_success_warms_cache_for_next_request( assert live_read_count == 1 +def test_public_matrix_cache_bypass_forces_shared_refresh(monkeypatch: Any) -> None: + stored_payloads: list[dict[str, Any]] = [] + + async def _unexpected_cache_read(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise AssertionError("cache bypass must not read a prior projection") + + async def _live_readback( + _project_id: str, + ) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: + return {}, [], {} + + async def _store_cache(_project_id: str, payload: dict[str, Any]) -> None: + stored_payloads.append(payload) + + monkeypatch.setattr( + matrix_service, "_read_public_matrix_cache", _unexpected_cache_read + ) + monkeypatch.setattr(matrix_service, "_load_live_rows", _live_readback) + monkeypatch.setattr(matrix_service, "_store_public_matrix_caches", _store_cache) + + payload = asyncio.run( + build_asset_capability_matrix_with_live_readback( + bypass_public_cache=True, + ) + ) + + assert payload["live_readback_status"] == "ready" + assert len(stored_payloads) == 1 + assert stored_payloads[0]["matrix_fingerprint"] == payload["matrix_fingerprint"] + + def test_public_matrix_timeout_uses_last_ready_cache(monkeypatch: Any) -> None: async def _slow_live_readback( _project_id: str, diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 33f6a1bc9..cf4bbab07 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1,5 +1,6 @@ import asyncio import inspect +import json from datetime import datetime from decimal import Decimal from types import SimpleNamespace @@ -32,6 +33,7 @@ from src.services.platform_operator_service import ( _ai_route_lane_state, _ai_route_policy_order, _ai_route_repair_evidence_item, + _apply_run_terminal_truth_precedence, _build_awooop_status_chain, _callback_reply_audit_summary_from_row, _callback_reply_event_item, @@ -2940,6 +2942,128 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["source_refs"]["refs"]["signoz_alerts"] == ["signoz:abc"] +def test_run_terminal_receipt_overrides_historical_incident_green_state() -> None: + historical_chain = { + "schema_version": "awooop_status_chain_v1", + "source": "truth_chain+adr100_history", + "source_id": "INC-BRR-35994B66C2807E74", + "incident_ids": ["INC-BRR-35994B66C2807E74"], + "current_stage": "execution_succeeded", + "stage_status": "success", + "verdict": "auto_repaired_verified", + "repair_state": "auto_repaired_verified", + "verification": "success", + "needs_human": False, + "next_step": "monitor_for_regression", + "operator_outcome": { + "state": "completed_verified", + "execution_result": { + "completion_status": "completed_verified", + "terminal": True, + }, + }, + "evidence": { + "auto_repair_records": 1, + "operation_records": 4, + "mcp_gateway_total": 8, + "knowledge_entries": 1, + }, + "writes": {"incident": True, "auto_repair": True}, + "mcp": {"gateway": {"total": 8, "success": 4}}, + "execution": {"operation_total": 4, "latest_status": "success"}, + } + run = SimpleNamespace( + run_id=UUID("f53d95ac-04d3-5228-ae74-9ab0ad95c013"), + state="failed", + error_code="E-AGENT99-OUTCOME-TIMEOUT", + timeout_at=datetime.fromisoformat("2026-07-15T00:22:32.527349"), + completed_at=datetime.fromisoformat("2026-07-15T01:23:15.118099"), + error_detail=json.dumps({ + "outcome_timeout_no_write_terminal": True, + "terminalizer_runtime_write_performed": False, + "outcome_runtime_write_status": ( + "unknown_without_authenticated_outcome" + ), + "transport_replayed": False, + "incident_resolution_authorized": False, + "runtime_closure_verified": False, + "safe_next_action": ( + "await_new_source_recurrence_after_agent99_relay_recovery" + ), + "verifier": { + "receipt": { + "timeout_at": "2026-07-15T00:22:32.527349", + "terminal_at": "2026-07-15T01:23:15.118099", + } + }, + }), + ) + + chain = _apply_run_terminal_truth_precedence( + run=run, + status_chain=historical_chain, + ) + + assert chain["source"] == ( + "run_terminal_receipt>historical_incident_context" + ) + assert chain["stage_status"] == "failed" + assert chain["repair_state"] == "failed_no_write_terminal" + assert chain["verification"] == "failed_authenticated_outcome_timeout" + assert chain["needs_human"] is False + assert chain["operator_outcome"]["state"] == "failed_no_write_terminal" + assert chain["operator_outcome"]["execution_result"][ + "completion_status" + ] == "failed_no_write_terminal" + assert chain["writes"] == {"incident": False, "auto_repair": False} + assert chain["evidence"]["auto_repair_records"] == 0 + assert chain["execution"]["operation_total"] == 0 + assert chain["run_terminal_precedence"] == { + "active": True, + "reason": "higher_priority_run_terminal_receipt", + "run_id": "f53d95ac-04d3-5228-ae74-9ab0ad95c013", + "run_state": "failed", + "error_code": "E-AGENT99-OUTCOME-TIMEOUT", + "outcome_timeout_no_write_terminal": True, + "timeout_at": "2026-07-15T00:22:32.527349", + "terminal_at": "2026-07-15T01:23:15.118099", + "terminalizer_runtime_write_performed": False, + "outcome_runtime_write_status": ( + "unknown_without_authenticated_outcome" + ), + "transport_replayed": False, + "incident_resolution_authorized": False, + "runtime_closure_verified": False, + "safe_next_action": ( + "await_new_source_recurrence_after_agent99_relay_recovery" + ), + } + assert chain["historical_incident_context"]["repair_state"] == ( + "auto_repaired_verified" + ) + assert chain["historical_incident_context"]["writes"] == { + "incident": True, + "auto_repair": True, + } + assert "_apply_run_terminal_truth_precedence" in inspect.getsource( + platform_operator_service.get_run_detail + ) + + +def test_run_terminal_precedence_leaves_unrelated_failed_run_unchanged() -> None: + chain = {"repair_state": "auto_repaired_verified"} + run = SimpleNamespace( + state="failed", + error_code="E-OTHER", + error_detail="{}", + ) + + assert _apply_run_terminal_truth_precedence( + run=run, + status_chain=chain, + ) == chain + + def test_awooop_status_chain_surfaces_controlled_ansible_apply_proof() -> None: chain = _build_awooop_status_chain( incident_ids=["INC-20260531-D6A3C4"], diff --git a/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py b/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py index bdaea07ac..ba34851c5 100644 --- a/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py +++ b/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from fastapi import FastAPI from fastapi.testclient import TestClient @@ -11,13 +13,20 @@ from src.services.gitea_actions_secret_variable_name_inventory import ( def test_gitea_actions_secret_variable_name_inventory_loader_is_value_safe() -> None: payload = load_latest_gitea_actions_secret_variable_name_inventory() + repo_root = Path(__file__).resolve().parents[3] + workflow_dir = repo_root / ".gitea" / "workflows" + committed_workflow_file_count = sum( + 1 + for path in workflow_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".yml", ".yaml"} + ) assert ( payload["schema_version"] == "gitea_actions_secret_variable_name_inventory_v1" ) assert payload["status"] == "gitea_actions_secret_variable_name_inventory_ready" assert payload["ready"] is True - assert payload["summary"]["workflow_file_count"] == 13 + assert payload["summary"]["workflow_file_count"] == committed_workflow_file_count assert payload["summary"]["referenced_secret_name_count"] == 26 assert payload["summary"]["referenced_variable_name_count"] == 0 assert payload["summary"]["secret_values_collected"] is False diff --git a/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py b/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py index 4448216bf..09fe07d73 100644 --- a/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py +++ b/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py @@ -139,6 +139,9 @@ def _readback_payload() -> dict: def test_gitea_capability_readback_rolls_up_complete_feature_surface() -> None: payload = _readback_payload() + workflow_inventory = payload["workflow_source_inventory"] + workflow_rows = workflow_inventory["workflow_rows"] + workflow_file_count = len(workflow_rows) assert ( payload["schema_version"] == "gitea_capability_ci_cd_optimization_readback_v1" @@ -148,20 +151,53 @@ def test_gitea_capability_readback_rolls_up_complete_feature_surface() -> None: assert payload["rollups"]["expected_product_count"] == 12 assert payload["rollups"]["ssh_verified_product_repo_count"] == 12 assert payload["rollups"]["dev_prod_environment_split_ready_count"] == 12 - assert payload["workflow_source_inventory"]["workflow_file_count"] == 13 - professional_feature_rollups = payload["workflow_source_inventory"][ + assert workflow_inventory["workflow_file_count"] == workflow_file_count + professional_feature_rollups = workflow_inventory[ "professional_feature_rollups" ] - assert professional_feature_rollups["gitea_ignored_syntax_workflow_count"] == 10 - assert professional_feature_rollups["otel_workflow_count"] == 3 - assert professional_feature_rollups["argocd_gitops_workflow_count"] == 1 - assert professional_feature_rollups["notification_or_webhook_workflow_count"] == 9 - assert ( - professional_feature_rollups["registry_or_package_channel_workflow_count"] == 4 + ignored_syntax_keys = { + "declares_concurrency", + "declares_permissions", + "declares_timeout_minutes", + "declares_continue_on_error", + "declares_environment", + } + assert professional_feature_rollups[ + "gitea_ignored_syntax_workflow_count" + ] == sum( + any(row.get(key) is True for key in ignored_syntax_keys) + for row in workflow_rows + ) + assert professional_feature_rollups["otel_workflow_count"] == sum( + row.get("uses_otel") is True for row in workflow_rows + ) + assert professional_feature_rollups["argocd_gitops_workflow_count"] == sum( + row.get("uses_argocd_gitops") is True for row in workflow_rows + ) + assert professional_feature_rollups[ + "notification_or_webhook_workflow_count" + ] == sum( + row.get("uses_notification_or_webhook") is True for row in workflow_rows + ) + assert ( + professional_feature_rollups["registry_or_package_channel_workflow_count"] + == sum( + row.get("uses_registry_or_package_channel") is True + for row in workflow_rows + ) + ) + assert professional_feature_rollups[ + "package_release_promotion_workflow_count" + ] == sum( + row.get("uses_package_release_promotion_contract") is True + for row in workflow_rows + ) + assert professional_feature_rollups["sbom_or_provenance_workflow_count"] == sum( + row.get("uses_sbom_or_provenance") is True for row in workflow_rows + ) + assert professional_feature_rollups["artifact_transfer_workflow_count"] == sum( + row.get("uses_artifact_transfer") is True for row in workflow_rows ) - assert professional_feature_rollups["package_release_promotion_workflow_count"] == 1 - assert professional_feature_rollups["sbom_or_provenance_workflow_count"] == 1 - assert professional_feature_rollups["artifact_transfer_workflow_count"] == 1 assert ( payload["workflow_source_inventory"]["secret_variable_rollups"][ "secret_values_collected" @@ -203,8 +239,8 @@ def test_gitea_capability_readback_rolls_up_complete_feature_surface() -> None: assert capability_by_id["wiki_knowledge_base"]["status"] == "source_ready" assert capability_by_id["gitea_actions_workflows"]["status"] == "verified_ready" assert capability_by_id["gitea_actions_workflows"]["metrics"] == { - "workflow_source_file_count": 13, - "workflow_contract_count": 13, + "workflow_source_file_count": workflow_file_count, + "workflow_contract_count": workflow_file_count, } assert capability_by_id["gitea_actions_runners"]["status"] == "verified_ready" assert ( diff --git a/apps/api/tests/test_gitea_workflow_runner_health_api.py b/apps/api/tests/test_gitea_workflow_runner_health_api.py index ee9734a94..a65eb96fe 100644 --- a/apps/api/tests/test_gitea_workflow_runner_health_api.py +++ b/apps/api/tests/test_gitea_workflow_runner_health_api.py @@ -20,9 +20,14 @@ def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): assert data["program_status"]["current_task_id"] == "P1-002" assert data["program_status"]["next_task_id"] == "P1-003" assert data["program_status"]["read_only_mode"] is True - assert data["rollups"]["total_workflows"] == len(data["workflow_records"]) == 13 - assert data["rollups"]["workflows_with_schedule"] == 5 - assert data["rollups"]["workflows_with_workflow_dispatch"] == 13 + workflow_records = data["workflow_records"] + assert data["rollups"]["total_workflows"] == len(workflow_records) + assert data["rollups"]["workflows_with_schedule"] == sum( + "schedule" in row["triggers"] for row in workflow_records + ) + assert data["rollups"]["workflows_with_workflow_dispatch"] == sum( + "workflow_dispatch" in row["triggers"] for row in workflow_records + ) assert data["rollups"]["workflow_ids_requiring_runner_attestation"] == [] assert data["rollups"]["runner_contracts_requiring_action"] == [] assert data["rollups"]["notification_contracts_quiet_success_count"] == 3 @@ -64,5 +69,16 @@ def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): if row["workflow_id"] == "harbor_110_local_repair" ) assert harbor_repair["runner_evidence_status"] == "non110_host_runner_mapped" + mcp_artifact_mirror = next( + row + for row in workflow_records + if row["workflow_id"] == "mcp_external_artifact_mirror" + ) + assert mcp_artifact_mirror["runner_labels"] == ["awoooi-non110-host"] + assert mcp_artifact_mirror["runner_evidence_status"] == ( + "non110_host_runner_mapped" + ) + assert mcp_artifact_mirror["notification_policy"] == "read_only_no_notify" + assert mcp_artifact_mirror["notify_bridge_calls"] == 0 assert "workflow 修改批准" in data["operator_contract"]["must_not_interpret_as"] assert "Secret 已讀取或可輸出" in data["operator_contract"]["must_not_interpret_as"] diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index 448ee8cba..4f358eae1 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -18,6 +18,22 @@ export interface AwoooPStatusChain { verification?: string | null; needs_human?: boolean | null; next_step?: string | null; + run_terminal_precedence?: { + active?: boolean | null; + reason?: string | null; + run_id?: string | null; + run_state?: string | null; + error_code?: string | null; + outcome_timeout_no_write_terminal?: boolean | null; + timeout_at?: string | null; + terminal_at?: string | null; + terminalizer_runtime_write_performed?: boolean | null; + outcome_runtime_write_status?: string | null; + transport_replayed?: boolean | null; + incident_resolution_authorized?: boolean | null; + runtime_closure_verified?: boolean | null; + safe_next_action?: string | null; + } | null; operator_outcome?: { schema_version?: string; state?: string | null; @@ -466,6 +482,13 @@ export interface AwoooPStatusChain { function toneClass(chain?: AwoooPStatusChain | null) { if (!chain) return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]"; + if ( + chain.run_terminal_precedence?.active === true + || chain.stage_status === "failed" + || String(chain.repair_state ?? "").startsWith("failed_") + ) { + return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; + } if (chain.repair_state === "auto_repaired_verified") { return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; } @@ -477,6 +500,11 @@ function toneClass(chain?: AwoooPStatusChain | null) { function chainIcon(chain?: AwoooPStatusChain | null) { if (!chain) return Route; + if ( + chain.run_terminal_precedence?.active === true + || chain.stage_status === "failed" + || String(chain.repair_state ?? "").startsWith("failed_") + ) return TriangleAlert; if (chain.repair_state === "auto_repaired_verified") return CheckCircle2; if (chain.needs_human || chain.repair_state === "blocked_manual_required") return ShieldCheck; return Activity; @@ -557,6 +585,7 @@ export function AwoooPStatusChainPanel({ approve_post_apply_verifier_plan: t("nextActions.reviewPostApplyVerifier"), assign_km_writeback_owner_after_verified_execution: t("nextActions.prepareKmPlaybookWriteback"), assign_playbook_trust_owner_after_verified_execution: t("nextActions.prepareKmPlaybookWriteback"), + await_new_source_recurrence_after_agent99_relay_recovery: t("nextActions.collectEvidenceOrWait"), }; return labels[key] ?? valueOrEmpty(value, emptyLabel); }; diff --git a/config/security/runtime-image-mirror-policy.json b/config/security/runtime-image-mirror-policy.json index 4a75d9c28..d1ba157dd 100644 --- a/config/security/runtime-image-mirror-policy.json +++ b/config/security/runtime-image-mirror-policy.json @@ -278,6 +278,29 @@ "max_surge": 1 } } + }, + { + "asset_id": "runtime-image:sealed-secrets-controller", + "source_index_digest": "sha256:74cd190f424b591dd82a234685c8c81d50b33af807e03470bc12b23d202e0106", + "platform_digest": "sha256:1827b36853e124ac0dd2554f7cd55c04952ecb7c38ff19a4a8d93d633811ed68", + "target_registry_digest": "sha256:2692460ed95f213760550cb508c881e1490a2f0ce4affbed31a0bf900ebba363", + "push_ref": "registry.wooo.work/awoooi/runtime-mirror/sealed-secrets-controller:0.26.0-linux-amd64", + "runtime_ref": "192.168.0.110:5000/awoooi/runtime-mirror/sealed-secrets-controller@sha256:2692460ed95f213760550cb508c881e1490a2f0ce4affbed31a0bf900ebba363", + "workload": { + "kind": "deployment", + "namespace": "kube-system", + "name": "sealed-secrets-controller", + "container": "sealed-secrets-controller" + }, + "availability_guard": { + "minimum_ready_replicas": 1, + "maximum_effective_unavailable": 0, + "minimum_effective_surge": 1, + "enforced_strategy": { + "max_unavailable": 0, + "max_surge": 1 + } + } } ] } diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 8f4eb97c4..bd7166202 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,25 @@ +## 2026-07-15 — P0 MCP Playwright immutable mirror 與獨立供應鏈 verifier + +**完成內容**: +- mcp.so 僅保留 discovery URL;可執行 bytes 只從 policy allowlist 的 npm registry exact URLs 取得。`@playwright/mcp=0.0.78`、`playwright` / `playwright-core=1.62.0-alpha-1783623505000` 全部固定 version、SRI SHA-512、SHA-1、npm ECDSA signature、SLSA subject、dependency closure 與 Apache-2.0 license。 +- 新增 fail-closed artifact controller:拒絕 redirect、非 allowlist host、floating version、dependency/metadata drift、unsafe/duplicate tar entry、已知 OSV vulnerability 與未通過簽章的 artifact;產生 deterministic CycloneDX 1.5 SBOM 與 scratch OCI data bundle,不執行 package install、MCP、browser 或 container。 +- 新增完全不 import controller 的獨立 verifier:由 Harbor immutable digest 重新 pull/save image、只把 layer 當資料讀取,限制 file/byte/path/symlink/whiteout,要求 exact 8-file bundle,再次重算 manifest/SBOM/tarball hash、以 OpenSSL 重驗 npm signature 並重解 SLSA DSSE subject。 +- Gitea `mcp-external-artifact-mirror.yaml` 不使用遠端 action;push、manual 與每週三 pinned revalidation 都先跑 controller/verifier tests、host-pressure gate、Harbor password-stdin、獨立 readback與 logout。receipt 以 ephemeral askpass normal push 到專用 `mcp-artifact-receipts` audit branch,不再推 main、取消有效 CD 或保存 credential。 +- Gitea run `#5174` 成功;controlled run `dce032df-0a9a-476f-940b-e61a94865e1b` 產生 internal digest `sha256:de002c418bbb94fb4609539c4259c39ccb7f8b353f8bac8fbcba9e5742ad79ed`,獨立 verifier 為 `completed_internal_mirror_verified`,audit branch commit `9c08ada5c0f15bb6924304af29cd13c8376182ff`。 + +**source/test evidence**: +- artifact controller/verifier focused tests `24 passed`;MCP control-plane/federation/version-lifecycle `20 passed`。 +- 新增第 14 條 Gitea workflow 後,CD `#5173` 以 `2627 passed / 1 failed` 找出 workflow health contract 仍固定 13;已同步 workflow health snapshot、secret-name inventory、Gitea capability readback 與 priority work-order count,聚焦回歸 `40 passed`,secret names 維持 `26`、secret values collected=`false`。 +- Ruff、py_compile、JSON/YAML parse、freeze/floating-reference guard 與 `git diff --check` 通過。 + +**尚未完成 / 不誤報**: +- 目前只完成 immutable internal mirror 與供應鏈 verifier;`deployment_allowed=false`、`replay_allowed=false`、`shadow_allowed=false`、`canary_allowed=false`。尚未實作 registered public-origin replay adapter、compatibility replay、shadow/canary、runtime upgrade、post-verifier 或 rollback execution。 +- 外部 catalog / tarball / attestation 未寫 RAG;只保留 normalized digest、SBOM 與 supply-chain receipt。未讀 secret value、`.env`、raw session、SQLite/auth;未連線或下載 GitHub / Actions / hosted artifact,也未使用 `npx -y`、`@latest`、`:latest`、force push 或 production route mutation。 +- 本筆 inventory drift 修正與 catalog mirror state 尚待最新 main CD/deploy marker/production UI readback;不得由 `#5174` mirror success 倒推 AWOOOI production 已部署。 + +**下一步**: +- 先取得包含本修正的 Gitea CD terminal與 production MCP page readback;再以 public-only origin allowlist 建立 repo-owned replay adapter,要求 compatibility、prompt-injection、latency、accessibility、session/file/download no-write verifier與 rollback receipt 全數通過,才可進第一個 noncritical shadow/canary。 + ## 2026-07-15 — P0 MCP EwoooC/MOMO durable production federation source closure **完成內容**: diff --git a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json index 694f595bd..fe7b0fd49 100644 --- a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json +++ b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json @@ -1,6 +1,6 @@ { "schema_version": "gitea_workflow_runner_health_v1", - "generated_at": "2026-07-09T23:20:00+08:00", + "generated_at": "2026-07-15T09:57:22+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", @@ -21,8 +21,10 @@ ".gitea/workflows/deploy-alerts.yaml", ".gitea/workflows/e2e-health.yaml", ".gitea/workflows/harbor-110-local-repair.yaml", + ".gitea/workflows/mcp-external-artifact-mirror.yaml", ".gitea/workflows/run-migration.yml", ".gitea/workflows/type-sync-check.yaml", + "config/mcp/playwright-mcp-artifact-policy.json", "docs/evaluations/ai_agent_version_lifecycle_update_proposal_2026-07-10.json", "scripts/ci/check-gitea-step-env-secrets.js", "scripts/ci/cleanup-host-runner-workspace.sh", @@ -32,22 +34,22 @@ "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], "rollups": { - "total_workflows": 13, + "total_workflows": 14, "by_workflow_status": { - "manifest_mapped": 13 + "manifest_mapped": 14 }, "by_runner_evidence_status": { "non110_runner_mapped": 10, - "non110_host_runner_mapped": 3 + "non110_host_runner_mapped": 4 }, - "workflows_with_schedule": 5, - "workflows_with_workflow_dispatch": 13, + "workflows_with_schedule": 6, + "workflows_with_workflow_dispatch": 14, "workflows_with_notify_bridge": 6, "workflows_with_actionable_or_failure_quiet_policy": 3, "workflow_ids_requiring_runner_attestation": [], "total_runner_contracts": 5, "runner_contracts_requiring_action": [], - "notification_contracts_total": 7, + "notification_contracts_total": 8, "notification_contracts_quiet_success_count": 3, "notification_contracts_quiet_success_ids": [ "agent_market_watch_actionable_only", @@ -337,6 +339,36 @@ ], "next_action": "維持 no-auto-apply repair guard;不得由 API 直接觸發 repair。" }, + { + "workflow_id": "mcp_external_artifact_mirror", + "file_ref": ".gitea/workflows/mcp-external-artifact-mirror.yaml", + "display_name": "MCP External Artifact Mirror", + "scope": "以 exact npm registry URL、committed SHA-512 與 Harbor digest 執行受控外部 MCP artifact mirror;只寫獨立 audit branch,不啟動 MCP、不改 production route。", + "status": "manifest_mapped", + "risk_level": "high", + "triggers": [ + "push:main", + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "每週三 10:13 Asia/Taipei;cron=13 2 * * 3 UTC", + "runner_labels": [ + "awoooi-non110-host" + ], + "runner_evidence_status": "non110_host_runner_mapped", + "job_count": 1, + "notification_policy": "read_only_no_notify", + "notify_bridge_calls": 0, + "secrets_policy_status": "只使用 workflow secret references 進行 Harbor 登入與獨立 audit branch normal push;本 snapshot 與 readback 不讀取、保存或顯示任何值。", + "evidence_refs": [ + ".gitea/workflows/mcp-external-artifact-mirror.yaml", + "config/mcp/playwright-mcp-artifact-policy.json", + "scripts/security/external_mcp_artifact_controller.py", + "scripts/security/verify_external_mcp_artifact_receipt.py", + "scripts/ci/wait-host-web-build-pressure.sh" + ], + "next_action": "維持 bounded capacity wait、獨立 verifier 與 mcp-artifact-receipts audit branch;不得啟動 artifact、寫 RAG、切 production route 或推回 deploy main。" + }, { "workflow_id": "run_migration", "file_ref": ".gitea/workflows/run-migration.yml", @@ -399,7 +431,8 @@ "used_by_workflows": [ "awoooi_onboarding_warning_step", "cd_pipeline", - "harbor_110_local_repair" + "harbor_110_local_repair", + "mcp_external_artifact_mirror" ], "health_contract": "host-level controlled workflows use the explicit awoooi-non110-host label; this snapshot does not restart or mutate the runner.", "guardrail_refs": [ @@ -409,7 +442,8 @@ "evidence_refs": [ ".gitea/workflows/cd.yaml", ".gitea/workflows/harbor-110-local-repair.yaml", - ".gitea/workflows/awoooi-onboarding-warning-step.yaml" + ".gitea/workflows/awoooi-onboarding-warning-step.yaml", + ".gitea/workflows/mcp-external-artifact-mirror.yaml" ], "next_action": "維持 explicit non110 host label;任何 runner/service 變更另走維護窗口。" }, @@ -613,6 +647,22 @@ ], "next_action": "後續補 alert/repair receipt;本 snapshot 不發通知、不觸發 migration 或 repair。" }, + { + "contract_id": "mcp_artifact_mirror_no_notify", + "display_name": "MCP artifact mirror no-notify", + "status": "preserved", + "policy_kind": "read_only_no_notify", + "success_noise_policy": "artifact mirror 與 verifier 成功只寫獨立 audit branch,不向 Telegram 或產品告警群組發送成功訊息。", + "failure_policy": "失敗留在 Gitea workflow 與脫敏 receipt;不得因通知失敗而重送 artifact、切 production route 或啟動 MCP。", + "workflow_refs": [ + "mcp_external_artifact_mirror" + ], + "evidence_refs": [ + ".gitea/workflows/mcp-external-artifact-mirror.yaml", + "config/mcp/playwright-mcp-artifact-policy.json" + ], + "next_action": "維持 no-notify 與獨立 audit branch;只在受控營運 readback 顯示 terminal receipt。" + }, { "contract_id": "lint_and_typecheck_no_notify", "display_name": "Lint / typecheck no-notify", diff --git a/docs/operations/mcp-control-plane-catalog.snapshot.json b/docs/operations/mcp-control-plane-catalog.snapshot.json index 312253008..7b5d6576b 100644 --- a/docs/operations/mcp-control-plane-catalog.snapshot.json +++ b/docs/operations/mcp-control-plane-catalog.snapshot.json @@ -301,18 +301,21 @@ "data_boundary": "public_and_test_surfaces", "deployment_allowed": false, "version_state": "exact_0.0.78_check_verified", - "digest_state": "upstream_sha512_pinned_internal_mirror_pending", - "sbom_state": "cyclonedx_1.5_check_verified_internal_mirror_pending", - "signature_state": "npm_registry_ecdsa_and_slsa_subject_verified", + "digest_state": "internal_mirror_digest_verified_sha256_de002c418bbb94fb4609539c4259c39ccb7f8b353f8bac8fbcba9e5742ad79ed", + "sbom_state": "cyclonedx_1.5_mirror_readback_verified", + "signature_state": "npm_registry_ecdsa_and_slsa_subject_independently_reverified", "rag_policy": "screenshots_and_accessibility_receipts_only", "blockers": [ - "internal_mirror_and_independent_digest_readback_pending", "registered_public_origin_replay_adapter_missing", + "compatibility_replay_not_executed", "shadow_canary_verifier_and_rollback_execution_pending" ], "evidence_refs": [ "config/mcp/playwright-mcp-artifact-policy.json", - "docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-check.snapshot.json" + "docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-check.snapshot.json", + "gitea:mcp-artifact-receipts@9c08ada5c0f15bb6924304af29cd13c8376182ff:docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-controller.snapshot.json", + "gitea:mcp-artifact-receipts@9c08ada5c0f15bb6924304af29cd13c8376182ff:docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-verifier.snapshot.json", + "registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp@sha256:de002c418bbb94fb4609539c4259c39ccb7f8b353f8bac8fbcba9e5742ad79ed" ] }, { diff --git a/scripts/security/tests/test_external_mcp_artifact_controller.py b/scripts/security/tests/test_external_mcp_artifact_controller.py index cfce6178c..273b68ef6 100644 --- a/scripts/security/tests/test_external_mcp_artifact_controller.py +++ b/scripts/security/tests/test_external_mcp_artifact_controller.py @@ -123,13 +123,16 @@ class ExternalMcpArtifactControllerTest(unittest.TestCase): self.assertNotIn("@latest", lowered) self.assertNotIn(":latest", lowered) self.assertNotIn("git push --force", lowered) + self.assertIn("cancel-in-progress: true", raw) self.assertIn("runs-on: awoooi-non110-host", raw) self.assertIn("docker login", raw) self.assertIn("--password-stdin", raw) self.assertIn("docker logout", raw) self.assertIn("verify_external_mcp_artifact_receipt.py", raw) - self.assertIn("git merge --no-edit gitea/main", raw) - self.assertIn("git push gitea HEAD:main", raw) + self.assertIn("RECEIPT_BRANCH: mcp-artifact-receipts", raw) + self.assertIn('git merge --no-edit FETCH_HEAD', raw) + self.assertIn('HEAD:refs/heads/${RECEIPT_BRANCH}', raw) + self.assertNotIn("git push gitea HEAD:main", raw) self.assertIn('cron: "13 2 * * 3"', raw) def test_dependency_closure_rejects_drift(self) -> None: diff --git a/scripts/security/tests/test_runtime_image_mirror_controller.py b/scripts/security/tests/test_runtime_image_mirror_controller.py index 8107e2496..e5ec0145c 100644 --- a/scripts/security/tests/test_runtime_image_mirror_controller.py +++ b/scripts/security/tests/test_runtime_image_mirror_controller.py @@ -81,7 +81,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): self.assertEqual(policy.work_item_id, "AIA-P0-006-02A") self.assertEqual(policy.risk_level, "high") - self.assertEqual(len(policy.images), 17) + self.assertEqual(len(policy.images), 18) self.assertNotIn("github.com", raw) self.assertNotIn("ghcr.io", raw) self.assertIn('"external_pull_allowed": false', raw) @@ -106,7 +106,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): ) ) container_kinds = [image.workload.container_kind for image in policy.images] - self.assertEqual(container_kinds.count("container"), 14) + self.assertEqual(container_kinds.count("container"), 15) self.assertEqual(container_kinds.count("init_container"), 3) vpa = next( @@ -176,6 +176,31 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): ), ) + sealed_secrets = next( + image + for image in policy.images + if image.asset_id == "runtime-image:sealed-secrets-controller" + ) + self.assertEqual( + sealed_secrets.target_registry_digest, + "sha256:2692460ed95f213760550cb508c881e1490a2f0ce4affbed31a0bf900ebba363", + ) + self.assertEqual(sealed_secrets.workload.namespace, "kube-system") + self.assertEqual(sealed_secrets.workload.name, "sealed-secrets-controller") + self.assertEqual( + sealed_secrets.workload.container, "sealed-secrets-controller" + ) + self.assertEqual( + sealed_secrets.availability_guard, + controller.AvailabilityGuard( + minimum_ready_replicas=1, + maximum_effective_unavailable=0, + minimum_effective_surge=1, + enforced_max_unavailable=0, + enforced_max_surge=1, + ), + ) + def test_policy_loads_and_validates_init_container_kind(self) -> None: image = self._image_with_container_kind("init_container")