diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 27d97b3ea..aeeb404ec 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -483,6 +483,9 @@ from src.services.telegram_alert_monitoring_live_receipt_apply import ( apply_latest_telegram_alert_monitoring_live_receipts, load_latest_telegram_alert_monitoring_live_receipt_apply_readback, ) +from src.services.windows99_vmx_source_locator_readback import ( + load_latest_windows99_vmx_source_locator_readback, +) from src.services.windows99_vmx_source_repair_package import ( load_latest_windows99_vmx_source_repair_package, mark_windows99_vmx_source_repair_public_redaction_boundary, @@ -1573,6 +1576,36 @@ async def get_windows99_vmx_source_repair_package() -> dict[str, Any]: ) from exc +@router.get( + "/windows99-vmx-source-locator-readback", + response_model=dict[str, Any], + summary="取得 Windows99 VMX source locator check-mode readback", + description=( + "讀取 P0-006 Windows99 VMX source locator 的 no-secret check-mode " + "readback。此端點不 SSH、不讀 VMX 內容、不回傳原始 VMX 路徑、不啟停 VM、" + "不寫 Windows、不改 registry/task、不重啟主機、不讀 secret。" + ), +) +async def get_windows99_vmx_source_locator_readback() -> dict[str, Any]: + """回傳 Windows99 VMX source locator check-mode readback。""" + try: + payload = await asyncio.to_thread( + load_latest_windows99_vmx_source_locator_readback + ) + return redact_public_lan_topology(payload) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error("windows99_vmx_source_locator_readback_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Windows99 VMX source locator readback 無效", + ) from exc + + @router.get( "/reboot-auto-recovery-drill-preflight", response_model=dict[str, Any], 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 a5d3db17e..f87327091 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -2984,6 +2984,9 @@ def _enrich_from_current_readbacks( apply_gitea_repo_bundle_backup_readback, load_latest_reboot_auto_recovery_slo_scorecard, ) + from src.services.windows99_vmx_source_locator_readback import ( + build_windows99_vmx_source_locator_readback, + ) from src.services.windows99_vmx_source_repair_package import ( build_windows99_vmx_source_repair_package, ) @@ -3029,6 +3032,12 @@ def _enrich_from_current_readbacks( windows99_vmx_source_repair_package = ( build_windows99_vmx_source_repair_package(reboot_slo) ) + windows99_vmx_source_locator_readback = ( + build_windows99_vmx_source_locator_readback( + reboot_slo, + repair_package=windows99_vmx_source_repair_package, + ) + ) windows99_vmx_source_repair_action_chain = _dict( windows99_vmx_source_repair_package.get("action_chain") ) @@ -3417,6 +3426,51 @@ def _enrich_from_current_readbacks( ) or "" ) + state["windows99_vmx_source_locator_status"] = str( + windows99_vmx_source_locator_readback.get("status") or "unknown" + ) + state["windows99_vmx_source_locator_required"] = bool( + windows99_vmx_source_locator_readback.get("source_locator_required") is True + ) + state["windows99_vmx_source_locator_check_mode_ready"] = bool( + windows99_vmx_source_locator_readback.get("locator_check_mode_ready") is True + ) + state["windows99_vmx_source_locator_target_aliases"] = _strings( + windows99_vmx_source_locator_readback.get("target_vm_aliases") + ) + state["windows99_vmx_source_locator_next_executable_step"] = str( + windows99_vmx_source_locator_readback.get("next_executable_step") or "" + ) + state["windows99_vmx_source_locator_expected_vmx_path_count"] = _int( + windows99_vmx_source_locator_readback.get("expected_vmx_path_count") + ) + state["windows99_vmx_source_locator_candidate_source_count_known"] = bool( + windows99_vmx_source_locator_readback.get("candidate_source_count_known") + is True + ) + state["windows99_vmx_source_locator_collector_readback_present"] = bool( + windows99_vmx_source_locator_readback.get("collector_readback_present") + is True + ) + state["windows99_vmx_source_locator_collector_collection_status"] = str( + windows99_vmx_source_locator_readback.get("collector_collection_status") + or "unknown" + ) + state["windows99_vmx_source_locator_raw_path_output"] = bool( + windows99_vmx_source_locator_readback.get("raw_path_output") is True + ) + state["windows99_vmx_source_locator_path_fingerprint_only"] = bool( + windows99_vmx_source_locator_readback.get("path_fingerprint_only") is True + ) + state["windows99_vmx_source_locator_file_content_read"] = bool( + windows99_vmx_source_locator_readback.get("file_content_read") is True + ) + state["windows99_vmx_source_locator_remote_write_performed"] = bool( + windows99_vmx_source_locator_readback.get("remote_write_performed") is True + ) + state["windows99_vmx_source_locator_vm_power_change_performed"] = bool( + windows99_vmx_source_locator_readback.get("vm_power_change_performed") is True + ) state["stale_snapshot_or_old_cd_runs_must_not_reopen_closed_work"] = True state["p0_004_template_copy_apply_gate_production_http_status"] = 200 state["p0_004_template_copy_apply_gate_runtime_readback_state"] = ( @@ -3759,6 +3813,48 @@ def _enrich_from_current_readbacks( evidence["windows99_vmx_source_repair_playbook_trust_writeback_ref"] = ( state["windows99_vmx_source_repair_playbook_trust_writeback_ref"] ) + evidence["windows99_vmx_source_locator_status"] = state[ + "windows99_vmx_source_locator_status" + ] + evidence["windows99_vmx_source_locator_required"] = state[ + "windows99_vmx_source_locator_required" + ] + evidence["windows99_vmx_source_locator_check_mode_ready"] = state[ + "windows99_vmx_source_locator_check_mode_ready" + ] + evidence["windows99_vmx_source_locator_target_aliases"] = state[ + "windows99_vmx_source_locator_target_aliases" + ] + evidence["windows99_vmx_source_locator_next_executable_step"] = state[ + "windows99_vmx_source_locator_next_executable_step" + ] + evidence["windows99_vmx_source_locator_expected_vmx_path_count"] = state[ + "windows99_vmx_source_locator_expected_vmx_path_count" + ] + evidence["windows99_vmx_source_locator_candidate_source_count_known"] = state[ + "windows99_vmx_source_locator_candidate_source_count_known" + ] + evidence["windows99_vmx_source_locator_collector_readback_present"] = state[ + "windows99_vmx_source_locator_collector_readback_present" + ] + evidence["windows99_vmx_source_locator_collector_collection_status"] = state[ + "windows99_vmx_source_locator_collector_collection_status" + ] + evidence["windows99_vmx_source_locator_raw_path_output"] = state[ + "windows99_vmx_source_locator_raw_path_output" + ] + evidence["windows99_vmx_source_locator_path_fingerprint_only"] = state[ + "windows99_vmx_source_locator_path_fingerprint_only" + ] + evidence["windows99_vmx_source_locator_file_content_read"] = state[ + "windows99_vmx_source_locator_file_content_read" + ] + evidence["windows99_vmx_source_locator_remote_write_performed"] = state[ + "windows99_vmx_source_locator_remote_write_performed" + ] + evidence["windows99_vmx_source_locator_vm_power_change_performed"] = state[ + "windows99_vmx_source_locator_vm_power_change_performed" + ] evidence["drill_preflight_status"] = str(reboot_preflight.get("status") or "") evidence["drill_preflight_ready"] = ( reboot_preflight_rollups.get("preflight_ready") is True @@ -4479,6 +4575,52 @@ def _set_rollups_and_summary( state.get("windows99_vmx_source_repair_playbook_trust_writeback_ref") or "" ), + "windows99_vmx_source_locator_status": str( + state.get("windows99_vmx_source_locator_status") or "unknown" + ), + "windows99_vmx_source_locator_required": ( + state.get("windows99_vmx_source_locator_required") is True + ), + "windows99_vmx_source_locator_check_mode_ready": ( + state.get("windows99_vmx_source_locator_check_mode_ready") is True + ), + "windows99_vmx_source_locator_target_aliases": _strings( + state.get("windows99_vmx_source_locator_target_aliases") + ), + "windows99_vmx_source_locator_next_executable_step": str( + state.get("windows99_vmx_source_locator_next_executable_step") or "" + ), + "windows99_vmx_source_locator_expected_vmx_path_count": _int( + state.get("windows99_vmx_source_locator_expected_vmx_path_count") + ), + "windows99_vmx_source_locator_candidate_source_count_known": ( + state.get("windows99_vmx_source_locator_candidate_source_count_known") + is True + ), + "windows99_vmx_source_locator_collector_readback_present": ( + state.get("windows99_vmx_source_locator_collector_readback_present") + is True + ), + "windows99_vmx_source_locator_collector_collection_status": str( + state.get("windows99_vmx_source_locator_collector_collection_status") + or "unknown" + ), + "windows99_vmx_source_locator_raw_path_output": ( + state.get("windows99_vmx_source_locator_raw_path_output") is True + ), + "windows99_vmx_source_locator_path_fingerprint_only": ( + state.get("windows99_vmx_source_locator_path_fingerprint_only") is True + ), + "windows99_vmx_source_locator_file_content_read": ( + state.get("windows99_vmx_source_locator_file_content_read") is True + ), + "windows99_vmx_source_locator_remote_write_performed": ( + state.get("windows99_vmx_source_locator_remote_write_performed") is True + ), + "windows99_vmx_source_locator_vm_power_change_performed": ( + state.get("windows99_vmx_source_locator_vm_power_change_performed") + is True + ), "p0_004_runtime_readback_ready": p0_004_ready, "reboot_drill_preflight_runtime_readback_ready": ( state.get("reboot_drill_preflight_runtime_readback_state") == "ready" @@ -4725,6 +4867,52 @@ def _set_rollups_and_summary( state.get("windows99_vmx_source_repair_playbook_trust_writeback_ref") or "" ), + "windows99_vmx_source_locator_status": str( + state.get("windows99_vmx_source_locator_status") or "unknown" + ), + "windows99_vmx_source_locator_required": ( + state.get("windows99_vmx_source_locator_required") is True + ), + "windows99_vmx_source_locator_check_mode_ready": ( + state.get("windows99_vmx_source_locator_check_mode_ready") is True + ), + "windows99_vmx_source_locator_target_aliases": _strings( + state.get("windows99_vmx_source_locator_target_aliases") + ), + "windows99_vmx_source_locator_next_executable_step": str( + state.get("windows99_vmx_source_locator_next_executable_step") or "" + ), + "windows99_vmx_source_locator_expected_vmx_path_count": _int( + state.get("windows99_vmx_source_locator_expected_vmx_path_count") + ), + "windows99_vmx_source_locator_candidate_source_count_known": ( + state.get("windows99_vmx_source_locator_candidate_source_count_known") + is True + ), + "windows99_vmx_source_locator_collector_readback_present": ( + state.get("windows99_vmx_source_locator_collector_readback_present") + is True + ), + "windows99_vmx_source_locator_collector_collection_status": str( + state.get("windows99_vmx_source_locator_collector_collection_status") + or "unknown" + ), + "windows99_vmx_source_locator_raw_path_output": ( + state.get("windows99_vmx_source_locator_raw_path_output") is True + ), + "windows99_vmx_source_locator_path_fingerprint_only": ( + state.get("windows99_vmx_source_locator_path_fingerprint_only") is True + ), + "windows99_vmx_source_locator_file_content_read": ( + state.get("windows99_vmx_source_locator_file_content_read") is True + ), + "windows99_vmx_source_locator_remote_write_performed": ( + state.get("windows99_vmx_source_locator_remote_write_performed") is True + ), + "windows99_vmx_source_locator_vm_power_change_performed": ( + state.get("windows99_vmx_source_locator_vm_power_change_performed") + is True + ), "windows99_winrm_http_open": ( state.get("windows99_winrm_http_open") is True ), diff --git a/apps/api/src/services/windows99_vmx_source_locator_readback.py b/apps/api/src/services/windows99_vmx_source_locator_readback.py new file mode 100644 index 000000000..ea0b10f69 --- /dev/null +++ b/apps/api/src/services/windows99_vmx_source_locator_readback.py @@ -0,0 +1,178 @@ +"""Windows99 VMX source locator readback. + +Builds the API-facing readback for the no-secret Windows99 VMX source locator. +The service only projects the current repair package into an executable +check-mode contract; it does not SSH, read VMX contents, write Windows state, +start VMs, stop VMs, restart services, or reboot hosts. +""" + +from __future__ import annotations + +from typing import Any + +from src.services.reboot_auto_recovery_slo_scorecard import ( + _taipei_now_iso, + load_latest_reboot_auto_recovery_slo_scorecard, +) +from src.services.windows99_vmx_source_repair_package import ( + build_windows99_vmx_source_repair_package, +) + +_SCHEMA_VERSION = "windows99_vmx_source_locator_readback_v1" +_COLLECTOR_SCHEMA_VERSION = "windows99_vmx_source_locator_collector_v1" +_LOCATOR_SCHEMA_VERSION = "windows99_vmx_source_locator_v1" +_COLLECTOR_SCRIPT_PATH = "scripts/reboot-recovery/locate-windows99-vmx-source.sh" +_LOCATOR_SCRIPT_PATH = "scripts/reboot-recovery/windows99-vmx-source-locator.ps1" + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _strings(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + return [] + + +def _bool(value: Any) -> bool: + return value is True + + +def _vm_rows_for_aliases( + repair_package: dict[str, Any], + target_aliases: list[str], +) -> list[dict[str, Any]]: + targets = set(target_aliases) + rows: list[dict[str, Any]] = [] + for row in repair_package.get("vm_rows") or []: + if not isinstance(row, dict): + continue + alias = str(row.get("alias") or "") + if alias in targets: + rows.append(row) + return rows + + +def build_windows99_vmx_source_locator_readback( + scorecard: dict[str, Any], + *, + repair_package: dict[str, Any] | None = None, + generated_at: str | None = None, +) -> dict[str, Any]: + package = repair_package or build_windows99_vmx_source_repair_package(scorecard) + missing_aliases = _strings(package.get("missing_vmx_aliases")) + target_aliases = missing_aliases or _strings(package.get("powered_off_aliases")) + target_rows = _vm_rows_for_aliases(package, missing_aliases) + expected_vmx_path_count = sum( + len(row.get("expected_vmx_candidates") or []) for row in target_rows + ) + source_locator_required = bool(missing_aliases) + locator_check_mode_ready = bool( + source_locator_required + and package.get("package_ready") is True + and package.get("check_mode_allowed_by_this_package") is True + ) + if locator_check_mode_ready: + status = "check_mode_locator_ready_windows99_vmx_source_required" + next_step = "run_no_secret_windows99_vmx_source_locator_collect" + safe_next_step = ( + "bash scripts/reboot-recovery/locate-windows99-vmx-source.sh " + "--collect" + ) + elif source_locator_required: + status = "blocked_locator_package_not_ready" + next_step = "repair_package_first" + safe_next_step = "rebuild_windows99_vmx_source_repair_package" + else: + status = "ready_no_windows99_vmx_source_locator_required" + next_step = "rerun_no_secret_vmware_verify_collector" + safe_next_step = ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh " + "--collect" + ) + + return { + "schema_version": _SCHEMA_VERSION, + "generated_at": generated_at + or str(scorecard.get("generated_at") or _taipei_now_iso()), + "status": status, + "target_host_alias": "99", + "target_vm_aliases": target_aliases, + "missing_vmx_aliases": missing_aliases, + "source_locator_required": source_locator_required, + "locator_check_mode_ready": locator_check_mode_ready, + "next_executable_step": next_step, + "safe_next_step": safe_next_step, + "collector_script_path": _COLLECTOR_SCRIPT_PATH, + "locator_script_path": _LOCATOR_SCRIPT_PATH, + "collector_schema_version": _COLLECTOR_SCHEMA_VERSION, + "locator_schema_version": _LOCATOR_SCHEMA_VERSION, + "collector_readback_present": False, + "collector_collection_status": "not_collected_by_api_readback", + "remote_locator_mode": "in_memory_stdin_scriptblock", + "expected_vmx_path_count": expected_vmx_path_count, + "expected_vmx_path_present_count": None, + "candidate_source_count": None, + "candidate_source_count_known": False, + "candidate_source_fingerprint_count": None, + "public_lan_topology_redacted": True, + "redacted_public_display_only": True, + "raw_path_output": False, + "path_fingerprint_only": True, + "file_content_read": False, + "secret_value_read": False, + "password_prompt_allowed": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_allowed_by_this_readback": False, + "vm_power_change_allowed": False, + "destructive_restore_allowed": False, + "operation_boundaries": { + "secret_value_read": False, + "password_prompt_allowed": False, + "file_content_read": False, + "raw_path_output": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + }, + "check_mode": { + "allowed": locator_check_mode_ready, + "path_existence_only": True, + "vmx_file_content_read": False, + "raw_path_output": False, + "path_fingerprint_only": True, + "collector_command": safe_next_step if locator_check_mode_ready else "", + }, + "target_selector": { + "host_alias": "99", + "vm_aliases": target_aliases, + "missing_vmx_aliases": missing_aliases, + "source_scope": "expected_vmx_paths_and_alias_named_vmx_candidates", + "secret_collection_allowed": False, + "vm_power_change_allowed": False, + "destructive_restore_allowed": False, + }, + "post_locator_verifier": [ + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect", + "GET /api/v1/agents/reboot-auto-recovery-slo-scorecard", + "GET /api/v1/agents/windows99-vmx-source-repair-package", + "GET /api/v1/agents/windows99-vmx-source-locator-readback", + ], + "source_repair_package_ready": _bool(package.get("package_ready")), + "source_repair_work_order_id": str(package.get("work_order_id") or ""), + "source_repair_action_stage": str(package.get("action_stage") or ""), + } + + +def load_latest_windows99_vmx_source_locator_readback() -> dict[str, Any]: + scorecard = load_latest_reboot_auto_recovery_slo_scorecard() + return build_windows99_vmx_source_locator_readback(scorecard) diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index edf65b888..26add1b57 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -737,6 +737,21 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a assert state[ "windows99_vmx_source_repair_playbook_trust_writeback_ref" ].startswith("playbook://awoooi/windows99-vmx-source-repair/") + assert state["windows99_vmx_source_locator_status"] == ( + "check_mode_locator_ready_windows99_vmx_source_required" + ) + assert state["windows99_vmx_source_locator_required"] is True + assert state["windows99_vmx_source_locator_check_mode_ready"] is True + assert state["windows99_vmx_source_locator_target_aliases"] == ["111"] + assert state["windows99_vmx_source_locator_next_executable_step"] == ( + "run_no_secret_windows99_vmx_source_locator_collect" + ) + assert state["windows99_vmx_source_locator_expected_vmx_path_count"] == 1 + assert state["windows99_vmx_source_locator_candidate_source_count_known"] is False + assert state["windows99_vmx_source_locator_collector_readback_present"] is False + assert state["windows99_vmx_source_locator_file_content_read"] is False + assert state["windows99_vmx_source_locator_remote_write_performed"] is False + assert state["windows99_vmx_source_locator_vm_power_change_performed"] is False assert payload["summary"]["controlled_service_data_backup_blocker_count"] == 0 assert payload["summary"]["active_p0_primary_blocker"] == ( "reboot_event_required_host_unreachable" @@ -814,6 +829,21 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a assert payload["rollups"]["windows99_vmx_source_repair_target_vm_aliases"] == [ "111" ] + assert payload["summary"]["windows99_vmx_source_locator_status"] == ( + "check_mode_locator_ready_windows99_vmx_source_required" + ) + assert payload["summary"]["windows99_vmx_source_locator_check_mode_ready"] is True + assert payload["summary"]["windows99_vmx_source_locator_target_aliases"] == [ + "111" + ] + assert ( + payload["rollups"]["windows99_vmx_source_locator_remote_write_performed"] + is False + ) + assert ( + payload["rollups"]["windows99_vmx_source_locator_file_content_read"] + is False + ) assert "service/data/backup readback is green" in payload[ "next_execution_order" ][0] @@ -863,6 +893,21 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a assert in_progress["evidence"][ "windows99_vmx_source_repair_playbook_trust_writeback_ref" ].startswith("playbook://awoooi/windows99-vmx-source-repair/") + assert in_progress["evidence"]["windows99_vmx_source_locator_status"] == ( + "check_mode_locator_ready_windows99_vmx_source_required" + ) + assert ( + in_progress["evidence"]["windows99_vmx_source_locator_check_mode_ready"] + is True + ) + assert ( + in_progress["evidence"]["windows99_vmx_source_locator_target_aliases"] + == ["111"] + ) + assert ( + in_progress["evidence"]["windows99_vmx_source_locator_remote_write_performed"] + is False + ) assert "Windows99 verify collection" in in_progress["professional_fix"][ "action" ] diff --git a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py new file mode 100644 index 000000000..22646acf5 --- /dev/null +++ b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services.windows99_vmx_source_locator_readback import ( + build_windows99_vmx_source_locator_readback, +) +from src.services.windows99_vmx_source_repair_package import ( + build_windows99_vmx_source_repair_package, +) + + +def _scorecard() -> dict: + return { + "status": "blocked_reboot_auto_recovery_slo_not_ready", + "generated_at": "2026-07-03T11:20:00+08:00", + "windows99_vmware_autostart": { + "required_vm_aliases": ["111", "188", "120", "121", "112"], + "missing_vmx_aliases": ["111"], + "powered_off_aliases": ["111", "112", "120", "121", "188"], + }, + "readback": { + "windows99_no_secret_collector_status": ( + "collected_windows99_vmware_verify_stdout" + ) + }, + } + + +def test_windows99_vmx_source_locator_builder_is_no_secret_check_mode() -> None: + repair_package = build_windows99_vmx_source_repair_package( + _scorecard(), + generated_at="2026-07-03T11:21:00+08:00", + ) + + payload = build_windows99_vmx_source_locator_readback( + _scorecard(), + repair_package=repair_package, + generated_at="2026-07-03T11:22:00+08:00", + ) + + assert payload["schema_version"] == "windows99_vmx_source_locator_readback_v1" + assert payload["status"] == ( + "check_mode_locator_ready_windows99_vmx_source_required" + ) + assert payload["target_host_alias"] == "99" + assert payload["target_vm_aliases"] == ["111"] + assert payload["source_locator_required"] is True + assert payload["locator_check_mode_ready"] is True + assert payload["next_executable_step"] == ( + "run_no_secret_windows99_vmx_source_locator_collect" + ) + assert payload["expected_vmx_path_count"] == 1 + assert payload["candidate_source_count_known"] is False + assert payload["collector_readback_present"] is False + assert payload["collector_script_path"] == ( + "scripts/reboot-recovery/locate-windows99-vmx-source.sh" + ) + assert payload["locator_script_path"] == ( + "scripts/reboot-recovery/windows99-vmx-source-locator.ps1" + ) + assert payload["check_mode"]["allowed"] is True + assert payload["check_mode"]["vmx_file_content_read"] is False + assert payload["raw_path_output"] is False + assert payload["path_fingerprint_only"] is True + assert payload["file_content_read"] is False + assert payload["secret_value_read"] is False + assert payload["password_prompt_allowed"] is False + assert payload["remote_write_performed"] is False + assert payload["host_reboot_performed"] is False + assert payload["vm_power_change_performed"] is False + assert payload["apply_allowed_by_this_readback"] is False + assert payload["destructive_restore_allowed"] is False + + +def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + agents, + "load_latest_windows99_vmx_source_locator_readback", + lambda: build_windows99_vmx_source_locator_readback( + _scorecard(), + generated_at="2026-07-03T11:22:00+08:00", + ), + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/windows99-vmx-source-locator-readback") + + assert response.status_code == 200 + payload = response.json() + assert payload["schema_version"] == "windows99_vmx_source_locator_readback_v1" + assert payload["status"] == ( + "check_mode_locator_ready_windows99_vmx_source_required" + ) + assert payload["target_vm_aliases"] == ["111"] + assert payload["locator_check_mode_ready"] is True + assert payload["public_lan_topology_redacted"] is True + assert payload["raw_path_output"] is False + assert payload["file_content_read"] is False + assert payload["remote_write_performed"] is False + assert payload["vm_power_change_performed"] is False + assert "192.168.0." not in response.text diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f27bf6ed0..81519c0f9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9379,7 +9379,22 @@ "verifiers": "verifier {count}", "learning": "learning refs {count}", "learningTargets": "LOG / KM / RAG / MCP / PlayBook", - "refs": "refs {count}" + "refs": "refs {count}", + "locator": "source locator", + "locatorReady": "locator ready", + "locatorWaiting": "locator wait", + "locatorTarget": "target {aliases}", + "locatorExpected": "expected {count}", + "locatorCandidatesKnown": "candidates known", + "locatorCandidatesUnknown": "candidates wait", + "locatorCollected": "collected", + "locatorPending": "collect wait", + "noRawPath": "no raw path", + "rawPathOpen": "raw path open", + "noFileRead": "no file read", + "fileReadOpen": "file read open", + "noWrite": "no write", + "writeOpen": "write open" }, "blockerLanes": { "label": "Blocker lanes", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index eb474a51f..2a43cbb18 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9379,7 +9379,22 @@ "verifiers": "verifier {count}", "learning": "learning refs {count}", "learningTargets": "LOG / KM / RAG / MCP / PlayBook", - "refs": "refs {count}" + "refs": "refs {count}", + "locator": "來源定位", + "locatorReady": "定位就緒", + "locatorWaiting": "等待定位", + "locatorTarget": "目標 {aliases}", + "locatorExpected": "預期 {count}", + "locatorCandidatesKnown": "候選已知", + "locatorCandidatesUnknown": "候選待收", + "locatorCollected": "已收件", + "locatorPending": "等待收件", + "noRawPath": "不吐原路徑", + "rawPathOpen": "原路徑開", + "noFileRead": "不讀檔", + "fileReadOpen": "讀檔開", + "noWrite": "不寫入", + "writeOpen": "寫入開" }, "blockerLanes": { "label": "卡點分群", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index a786b2a31..79f669f1c 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1183,6 +1183,20 @@ type PriorityWorkOrderResponse = { windows99_vmx_source_repair_rag_chunk_ref?: string | null; windows99_vmx_source_repair_mcp_evidence_ref?: string | null; windows99_vmx_source_repair_playbook_trust_writeback_ref?: string | null; + windows99_vmx_source_locator_status?: string | null; + windows99_vmx_source_locator_required?: boolean | null; + windows99_vmx_source_locator_check_mode_ready?: boolean | null; + windows99_vmx_source_locator_target_aliases?: string[] | null; + windows99_vmx_source_locator_next_executable_step?: string | null; + windows99_vmx_source_locator_expected_vmx_path_count?: number | null; + windows99_vmx_source_locator_candidate_source_count_known?: boolean | null; + windows99_vmx_source_locator_collector_readback_present?: boolean | null; + windows99_vmx_source_locator_collector_collection_status?: string | null; + windows99_vmx_source_locator_raw_path_output?: boolean | null; + windows99_vmx_source_locator_path_fingerprint_only?: boolean | null; + windows99_vmx_source_locator_file_content_read?: boolean | null; + windows99_vmx_source_locator_remote_write_performed?: boolean | null; + windows99_vmx_source_locator_vm_power_change_performed?: boolean | null; ai_loop_log_source_grouping_key_count?: number | null; ai_loop_log_source_grouping_keys?: string[] | null; ai_loop_log_source_tagging_contract_count?: number | null; @@ -1249,6 +1263,20 @@ type PriorityWorkOrderResponse = { windows99_vmx_source_repair_rag_chunk_ref?: string | null; windows99_vmx_source_repair_mcp_evidence_ref?: string | null; windows99_vmx_source_repair_playbook_trust_writeback_ref?: string | null; + windows99_vmx_source_locator_status?: string | null; + windows99_vmx_source_locator_required?: boolean | null; + windows99_vmx_source_locator_check_mode_ready?: boolean | null; + windows99_vmx_source_locator_target_aliases?: string[] | null; + windows99_vmx_source_locator_next_executable_step?: string | null; + windows99_vmx_source_locator_expected_vmx_path_count?: number | null; + windows99_vmx_source_locator_candidate_source_count_known?: boolean | null; + windows99_vmx_source_locator_collector_readback_present?: boolean | null; + windows99_vmx_source_locator_collector_collection_status?: string | null; + windows99_vmx_source_locator_raw_path_output?: boolean | null; + windows99_vmx_source_locator_path_fingerprint_only?: boolean | null; + windows99_vmx_source_locator_file_content_read?: boolean | null; + windows99_vmx_source_locator_remote_write_performed?: boolean | null; + windows99_vmx_source_locator_vm_power_change_performed?: boolean | null; drill_preflight_blocker_count?: number | null; ai_loop_current_blocker_log_source_tags?: AiLoopLogSourceTag[] | null; ai_loop_log_source_tagging_contract?: AiLoopLogSourceContract[] | null; @@ -1385,6 +1413,23 @@ const COMMANDER_INSERTED_REQUIREMENT_FALLBACK: PriorityWorkOrderResponse = { "mcp://awoooi/windows99/windows99-vmx-source-repair-111", windows99_vmx_source_repair_playbook_trust_writeback_ref: "playbook://awoooi/windows99-vmx-source-repair/windows99-vmx-source-repair-111", + windows99_vmx_source_locator_status: + "check_mode_locator_ready_windows99_vmx_source_required", + windows99_vmx_source_locator_required: true, + windows99_vmx_source_locator_check_mode_ready: true, + windows99_vmx_source_locator_target_aliases: ["111"], + windows99_vmx_source_locator_next_executable_step: + "run_no_secret_windows99_vmx_source_locator_collect", + windows99_vmx_source_locator_expected_vmx_path_count: 1, + windows99_vmx_source_locator_candidate_source_count_known: false, + windows99_vmx_source_locator_collector_readback_present: false, + windows99_vmx_source_locator_collector_collection_status: + "not_collected_by_api_readback", + windows99_vmx_source_locator_raw_path_output: false, + windows99_vmx_source_locator_path_fingerprint_only: true, + windows99_vmx_source_locator_file_content_read: false, + windows99_vmx_source_locator_remote_write_performed: false, + windows99_vmx_source_locator_vm_power_change_performed: false, stockplatform_public_api_runtime_status: "stockplatform_public_api_runtime_ready", stockplatform_public_api_controlled_recovery_preflight_status: "not_required_stockplatform_runtime_ready", @@ -1482,6 +1527,23 @@ const COMMANDER_INSERTED_REQUIREMENT_FALLBACK: PriorityWorkOrderResponse = { "mcp://awoooi/windows99/windows99-vmx-source-repair-111", windows99_vmx_source_repair_playbook_trust_writeback_ref: "playbook://awoooi/windows99-vmx-source-repair/windows99-vmx-source-repair-111", + windows99_vmx_source_locator_status: + "check_mode_locator_ready_windows99_vmx_source_required", + windows99_vmx_source_locator_required: true, + windows99_vmx_source_locator_check_mode_ready: true, + windows99_vmx_source_locator_target_aliases: ["111"], + windows99_vmx_source_locator_next_executable_step: + "run_no_secret_windows99_vmx_source_locator_collect", + windows99_vmx_source_locator_expected_vmx_path_count: 1, + windows99_vmx_source_locator_candidate_source_count_known: false, + windows99_vmx_source_locator_collector_readback_present: false, + windows99_vmx_source_locator_collector_collection_status: + "not_collected_by_api_readback", + windows99_vmx_source_locator_raw_path_output: false, + windows99_vmx_source_locator_path_fingerprint_only: true, + windows99_vmx_source_locator_file_content_read: false, + windows99_vmx_source_locator_remote_write_performed: false, + windows99_vmx_source_locator_vm_power_change_performed: false, drill_preflight_blocker_count: 5, stockplatform_public_api_runtime_status: "stockplatform_public_api_runtime_ready", stockplatform_public_api_runtime_ready: true, @@ -9279,6 +9341,42 @@ function ManagerSituationBoard({ summary?.windows99_vmx_source_repair_playbook_trust_writeback_ref ?? evidence?.windows99_vmx_source_repair_playbook_trust_writeback_ref, ].filter((value): value is string => Boolean(value)); + const vmxSourceLocatorReady = + summary?.windows99_vmx_source_locator_check_mode_ready ?? + evidence?.windows99_vmx_source_locator_check_mode_ready ?? + false; + const vmxSourceLocatorTargets = ( + summary?.windows99_vmx_source_locator_target_aliases ?? + evidence?.windows99_vmx_source_locator_target_aliases ?? + [] + ).filter(Boolean); + const vmxSourceLocatorTargetLabel = vmxSourceLocatorTargets.length + ? vmxSourceLocatorTargets.join(", ") + : "0"; + const vmxSourceLocatorExpectedCount = + summary?.windows99_vmx_source_locator_expected_vmx_path_count ?? + evidence?.windows99_vmx_source_locator_expected_vmx_path_count ?? + 0; + const vmxSourceLocatorCandidateKnown = + summary?.windows99_vmx_source_locator_candidate_source_count_known ?? + evidence?.windows99_vmx_source_locator_candidate_source_count_known ?? + false; + const vmxSourceLocatorCollectorPresent = + summary?.windows99_vmx_source_locator_collector_readback_present ?? + evidence?.windows99_vmx_source_locator_collector_readback_present ?? + false; + const vmxSourceLocatorRawPathOutput = + summary?.windows99_vmx_source_locator_raw_path_output ?? + evidence?.windows99_vmx_source_locator_raw_path_output ?? + false; + const vmxSourceLocatorFileContentRead = + summary?.windows99_vmx_source_locator_file_content_read ?? + evidence?.windows99_vmx_source_locator_file_content_read ?? + false; + const vmxSourceLocatorRemoteWrite = + summary?.windows99_vmx_source_locator_remote_write_performed ?? + evidence?.windows99_vmx_source_locator_remote_write_performed ?? + false; const vmxRepairMissingLabel = vmxRepairMissingAliases.length ? vmxRepairMissingAliases.join(", ") : "0"; @@ -9606,6 +9704,62 @@ function ManagerSituationBoard({ {t("vmxRepair.refs", { count: vmxRepairWritebackRefs.length })} +
+ + + + {vmxSourceLocatorReady + ? t("vmxRepair.locatorReady") + : t("vmxRepair.locatorWaiting")} + + + {t("vmxRepair.locatorTarget", { + aliases: vmxSourceLocatorTargetLabel, + })} + + + {t("vmxRepair.locatorExpected", { + count: vmxSourceLocatorExpectedCount, + })} + + + {vmxSourceLocatorCandidateKnown + ? t("vmxRepair.locatorCandidatesKnown") + : t("vmxRepair.locatorCandidatesUnknown")} + + + {vmxSourceLocatorCollectorPresent + ? t("vmxRepair.locatorCollected") + : t("vmxRepair.locatorPending")} + + + {vmxSourceLocatorRawPathOutput + ? t("vmxRepair.rawPathOpen") + : t("vmxRepair.noRawPath")} + + + {vmxSourceLocatorFileContentRead + ? t("vmxRepair.fileReadOpen") + : t("vmxRepair.noFileRead")} + + + {vmxSourceLocatorRemoteWrite + ? t("vmxRepair.writeOpen") + : t("vmxRepair.noWrite")} + +
{t("blockerLanes.label")}
diff --git a/scripts/reboot-recovery/locate-windows99-vmx-source.sh b/scripts/reboot-recovery/locate-windows99-vmx-source.sh new file mode 100755 index 000000000..a4b3bf6f9 --- /dev/null +++ b/scripts/reboot-recovery/locate-windows99-vmx-source.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +set -u + +MODE="check" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_HOST="${WINDOWS99_HOST:-192.168.0.99}" +CONNECT_TIMEOUT="${WINDOWS99_CONNECT_TIMEOUT:-3}" +SSH_TIMEOUT="${WINDOWS99_SSH_TIMEOUT:-3}" +REMOTE_LOCATOR_TIMEOUT="${WINDOWS99_REMOTE_LOCATOR_TIMEOUT:-60}" +SSH_PORT="${WINDOWS99_SSH_PORT:-22}" +MAX_AUTH_USERS="${WINDOWS99_MAX_AUTH_USERS:-}" +MAX_AUTH_USERS_EXPLICIT=0 +if [[ -n "${WINDOWS99_MAX_AUTH_USERS:-}" ]]; then + MAX_AUTH_USERS_EXPLICIT=1 +fi +KNOWN_HOSTS_FILE="${WINDOWS99_KNOWN_HOSTS_FILE:-/tmp/awoooi-windows99-known_hosts}" +LOCAL_LOCATOR_SCRIPT="${WINDOWS99_LOCAL_LOCATOR_SCRIPT:-${SCRIPT_DIR}/windows99-vmx-source-locator.ps1}" +REMOTE_LOCATOR_COMMAND="${WINDOWS99_REMOTE_LOCATOR_COMMAND:-powershell -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create([Console]::In.ReadToEnd())) -Mode Check\"}" +SSH_USERS=(ogt wooo ooo administrator Administrator) +SSH_USERS_EXPLICIT=0 + +if [[ -n "${WINDOWS99_SSH_USERS:-}" ]]; then + # shellcheck disable=SC2206 + SSH_USERS=(${WINDOWS99_SSH_USERS}) + SSH_USERS_EXPLICIT=1 +fi + +is_positive_int() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +usage() { + printf '%s\n' "usage: $0 [--check|--collect] [--host HOST] [--users 'u1 u2'] [--timeout SECONDS] [--max-auth-users N]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + MODE="check" + ;; + --collect) + MODE="collect" + ;; + --host) + shift + TARGET_HOST="${1:-}" + ;; + --users) + shift + # shellcheck disable=SC2206 + SSH_USERS=(${1:-}) + SSH_USERS_EXPLICIT=1 + ;; + --timeout) + shift + CONNECT_TIMEOUT="${1:-5}" + SSH_TIMEOUT="${CONNECT_TIMEOUT}" + ;; + --max-auth-users) + shift + MAX_AUTH_USERS="${1:-}" + MAX_AUTH_USERS_EXPLICIT=1 + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf '%s\n' "error=unknown_argument:$1" >&2 + usage >&2 + exit 64 + ;; + esac + shift +done + +if ! is_positive_int "${CONNECT_TIMEOUT}"; then + CONNECT_TIMEOUT=3 +fi +if ! is_positive_int "${SSH_TIMEOUT}"; then + SSH_TIMEOUT=3 +fi +if ! is_positive_int "${REMOTE_LOCATOR_TIMEOUT}"; then + REMOTE_LOCATOR_TIMEOUT=60 +fi +if ! is_positive_int "${MAX_AUTH_USERS}"; then + MAX_AUTH_USERS="${#SSH_USERS[@]}" +fi +if [[ "${SSH_USERS_EXPLICIT}" == "1" && "${MAX_AUTH_USERS_EXPLICIT}" != "1" ]]; then + MAX_AUTH_USERS="${#SSH_USERS[@]}" +fi +if ! is_positive_int "${MAX_AUTH_USERS}"; then + MAX_AUTH_USERS=2 +fi + +if [[ "${MODE}" != "check" && "${MODE}" != "collect" ]]; then + printf '%s\n' "error=invalid_mode:${MODE}" >&2 + exit 64 +fi + +PORT_TIMEOUT_WRAPPER="none" +if command -v timeout >/dev/null 2>&1; then + PORT_TIMEOUT_WRAPPER="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + PORT_TIMEOUT_WRAPPER="gtimeout" +fi + +port_open() { + local port="$1" + if ! command -v nc >/dev/null 2>&1; then + return 1 + fi + if [[ "${PORT_TIMEOUT_WRAPPER}" == "timeout" ]]; then + timeout "$((CONNECT_TIMEOUT + 1))s" nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + elif [[ "${PORT_TIMEOUT_WRAPPER}" == "gtimeout" ]]; then + gtimeout "$((CONNECT_TIMEOUT + 1))s" nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + else + nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + fi +} + +bool_for_port() { + local port="$1" + if port_open "${port}"; then + printf '1' + else + printf '0' + fi +} + +join_users() { + local joined="" + local user + for user in "${SSH_USERS[@]}"; do + if [[ -z "${joined}" ]]; then + joined="${user}" + else + joined="${joined},${user}" + fi + done + printf '%s' "${joined}" +} + +PORT_22_OPEN="$(bool_for_port 22)" +PORT_3389_OPEN="$(bool_for_port 3389)" +PORT_5985_OPEN="$(bool_for_port 5985)" +PORT_5986_OPEN="$(bool_for_port 5986)" + +SSH_BATCHMODE_AUTH_READY=0 +SSH_AUTHENTICATED_USER="" +SSH_AUTH_PROBE_EXIT_STATUS="not_attempted" +SSH_AUTH_PROBE_STDOUT_PRESENT=0 +SSH_AUTH_ATTEMPTED_USERS="$(join_users)" +SSH_AUTH_PROBED_USERS=0 +SSH_TIMEOUT_WRAPPER="none" +if command -v timeout >/dev/null 2>&1; then + SSH_TIMEOUT_WRAPPER="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + SSH_TIMEOUT_WRAPPER="gtimeout" +fi + +SSH_OPTS=( + -o BatchMode=yes + -o PreferredAuthentications=publickey + -o PubkeyAuthentication=yes + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o NumberOfPasswordPrompts=0 + -o ConnectTimeout="${SSH_TIMEOUT}" + -o ConnectionAttempts=1 + -o GSSAPIAuthentication=no + -o LogLevel=ERROR + -o StrictHostKeyChecking=no + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" + -p "${SSH_PORT}" +) + +run_ssh_timed() { + local user="$1" + local timeout_seconds="$2" + local timeout_wrapper_seconds="$((timeout_seconds + 1))" + shift 2 + if ! is_positive_int "${timeout_seconds}"; then + timeout_seconds="${SSH_TIMEOUT}" + timeout_wrapper_seconds="$((SSH_TIMEOUT + 1))" + fi + if [[ "${SSH_TIMEOUT_WRAPPER}" == "timeout" ]]; then + timeout "${timeout_wrapper_seconds}s" ssh "${SSH_OPTS[@]}" -o ConnectTimeout="${timeout_seconds}" "${user}@${TARGET_HOST}" "$@" + elif [[ "${SSH_TIMEOUT_WRAPPER}" == "gtimeout" ]]; then + gtimeout "${timeout_wrapper_seconds}s" ssh "${SSH_OPTS[@]}" -o ConnectTimeout="${timeout_seconds}" "${user}@${TARGET_HOST}" "$@" + else + ssh "${SSH_OPTS[@]}" -o ConnectTimeout="${timeout_seconds}" "${user}@${TARGET_HOST}" "$@" + fi +} + +run_ssh() { + local user="$1" + shift + run_ssh_timed "${user}" "${SSH_TIMEOUT}" "$@" +} + +if [[ "${PORT_22_OPEN}" == "1" ]]; then + for user in "${SSH_USERS[@]}"; do + if [[ "${SSH_AUTH_PROBED_USERS}" -ge "${MAX_AUTH_USERS}" ]]; then + break + fi + SSH_AUTH_PROBED_USERS=$((SSH_AUTH_PROBED_USERS + 1)) + auth_output="" + if auth_output="$(run_ssh "${user}" "echo AWOOOI_WINDOWS99_SSH_READY" 2>&1)"; then + SSH_BATCHMODE_AUTH_READY=1 + SSH_AUTHENTICATED_USER="${user}" + SSH_AUTH_PROBE_EXIT_STATUS=0 + if [[ -n "${auth_output}" ]]; then + SSH_AUTH_PROBE_STDOUT_PRESENT=1 + fi + break + else + SSH_AUTH_PROBE_EXIT_STATUS=$? + fi + done +fi + +DRY_RUN="true" +REMOTE_LOCATOR_ATTEMPTED=0 +REMOTE_LOCATOR_EXIT_STATUS="not_attempted" +LOCATOR_COLLECTION_STATUS="blocked_ssh_publickey_auth_missing" +SAFE_NEXT_STEP="select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_then_rerun_locator_no_password" +REMOTE_LOCATOR_OUTPUT="" +LOCAL_LOCATOR_SCRIPT_PRESENT=0 +if [[ -f "${LOCAL_LOCATOR_SCRIPT}" ]]; then + LOCAL_LOCATOR_SCRIPT_PRESENT=1 +fi +PROCESS_EXIT_STATUS=0 + +if [[ "${PORT_22_OPEN}" != "1" ]]; then + LOCATOR_COLLECTION_STATUS="blocked_ssh_port_closed" + SAFE_NEXT_STEP="enable_existing_ssh_management_channel_publickey_only_then_rerun_locator_no_secret" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi +elif [[ "${SSH_BATCHMODE_AUTH_READY}" != "1" ]]; then + LOCATOR_COLLECTION_STATUS="blocked_ssh_publickey_auth_missing" + SAFE_NEXT_STEP="select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_then_rerun_locator_no_password" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi +elif [[ "${MODE}" == "check" ]]; then + LOCATOR_COLLECTION_STATUS="ready_ssh_batchmode_auth_probe_only" + SAFE_NEXT_STEP="rerun_locator_with_collect_then_commit_no_secret_locator_artifact_and_scorecard_rerun" +elif [[ "${LOCAL_LOCATOR_SCRIPT_PRESENT}" != "1" ]]; then + DRY_RUN="false" + LOCATOR_COLLECTION_STATUS="blocked_local_locator_script_missing" + SAFE_NEXT_STEP="restore_windows99_vmx_source_locator_ps1_source_then_rerun_no_secret_no_remote_write" + PROCESS_EXIT_STATUS=75 +else + DRY_RUN="false" + REMOTE_LOCATOR_ATTEMPTED=1 + if REMOTE_LOCATOR_OUTPUT="$(run_ssh_timed "${SSH_AUTHENTICATED_USER}" "${REMOTE_LOCATOR_TIMEOUT}" "${REMOTE_LOCATOR_COMMAND}" <"${LOCAL_LOCATOR_SCRIPT}" 2>&1)"; then + REMOTE_LOCATOR_OUTPUT="${REMOTE_LOCATOR_OUTPUT//$'\r'/}" + REMOTE_LOCATOR_EXIT_STATUS=0 + if grep -q '^AWOOOI_WINDOWS99_VMX_SOURCE_LOCATOR=1$' <<<"${REMOTE_LOCATOR_OUTPUT}" && grep -q '^MODE=Check$' <<<"${REMOTE_LOCATOR_OUTPUT}"; then + LOCATOR_COLLECTION_STATUS="collected_windows99_vmx_source_locator_stdout" + SAFE_NEXT_STEP="commit_no_secret_locator_artifact_then_rerun_reboot_auto_recovery_slo_scorecard" + else + LOCATOR_COLLECTION_STATUS="blocked_remote_locator_output_invalid" + SAFE_NEXT_STEP="inspect_no_secret_locator_stdout_then_fix_in_memory_locator_script_and_rerun" + PROCESS_EXIT_STATUS=75 + fi + else + REMOTE_LOCATOR_EXIT_STATUS=$? + LOCATOR_COLLECTION_STATUS="blocked_remote_locator_command_failed" + SAFE_NEXT_STEP="inspect_no_secret_locator_stdout_then_fix_in_memory_locator_script_and_rerun" + PROCESS_EXIT_STATUS=75 + fi +fi + +printf '%s\n' "schema_version=windows99_vmx_source_locator_collector_v1" +printf '%s\n' "dry_run=${DRY_RUN}" +printf '%s\n' "target_host=${TARGET_HOST}" +printf '%s\n' "target_host_alias=99" +printf '%s\n' "target_vm_alias=111" +printf '%s\n' "connect_timeout_seconds=${CONNECT_TIMEOUT}" +printf '%s\n' "ssh_timeout_seconds=${SSH_TIMEOUT}" +printf '%s\n' "remote_locator_timeout_seconds=${REMOTE_LOCATOR_TIMEOUT}" +printf '%s\n' "port_timeout_wrapper=${PORT_TIMEOUT_WRAPPER}" +printf '%s\n' "ssh_auth_probe_user_limit=${MAX_AUTH_USERS}" +printf '%s\n' "ssh_timeout_wrapper=${SSH_TIMEOUT_WRAPPER}" +printf '%s\n' "port_22_open=${PORT_22_OPEN}" +printf '%s\n' "port_3389_open=${PORT_3389_OPEN}" +printf '%s\n' "port_5985_open=${PORT_5985_OPEN}" +printf '%s\n' "port_5986_open=${PORT_5986_OPEN}" +printf '%s\n' "ssh_candidate_users=${SSH_AUTH_ATTEMPTED_USERS}" +printf '%s\n' "ssh_auth_probed_users=${SSH_AUTH_PROBED_USERS}" +printf '%s\n' "ssh_batchmode_auth_ready=${SSH_BATCHMODE_AUTH_READY}" +printf '%s\n' "ssh_authenticated_user=${SSH_AUTHENTICATED_USER}" +printf '%s\n' "ssh_auth_probe_exit_status=${SSH_AUTH_PROBE_EXIT_STATUS}" +printf '%s\n' "ssh_auth_probe_stdout_present=${SSH_AUTH_PROBE_STDOUT_PRESENT}" +printf '%s\n' "remote_locator_mode=in_memory_stdin_scriptblock" +printf '%s\n' "local_locator_script_present=${LOCAL_LOCATOR_SCRIPT_PRESENT}" +printf '%s\n' "remote_locator_attempted=${REMOTE_LOCATOR_ATTEMPTED}" +printf '%s\n' "remote_locator_exit_status=${REMOTE_LOCATOR_EXIT_STATUS}" +printf '%s\n' "locator_collection_status=${LOCATOR_COLLECTION_STATUS}" +printf '%s\n' "safe_next_step=${SAFE_NEXT_STEP}" +printf '%s\n' "secret_value_read=false" +printf '%s\n' "password_prompt_allowed=false" +printf '%s\n' "remote_write_performed=false" +printf '%s\n' "host_reboot_performed=false" +printf '%s\n' "vm_power_change_performed=false" +printf '%s\n' "windows_registry_apply_performed=false" +printf '%s\n' "scheduled_task_write_performed=false" +printf '%s\n' "vmx_file_content_read=false" +printf '%s\n' "raw_path_output=false" + +if [[ "${REMOTE_LOCATOR_ATTEMPTED}" == "1" ]]; then + printf '%s\n' "remote_locator_output_begin" + printf '%s\n' "${REMOTE_LOCATOR_OUTPUT}" + printf '%s\n' "remote_locator_output_end" +fi + +exit "${PROCESS_EXIT_STATUS}" diff --git a/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py b/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py new file mode 100644 index 000000000..ed6db376f --- /dev/null +++ b/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +BASH_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "locate-windows99-vmx-source.sh" +PS_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "windows99-vmx-source-locator.ps1" + + +def _write_executable(path: Path, text: str) -> None: + path.write_text(textwrap.dedent(text).lstrip()) + path.chmod(0o755) + + +def _run_locator(fake_bin: Path, *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + env["WINDOWS99_KNOWN_HOSTS_FILE"] = str(fake_bin / "known_hosts") + return subprocess.run( + ["bash", str(BASH_SCRIPT), *args], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _key_values(stdout: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in stdout.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key] = value + return values + + +def test_locator_contract_forbids_secret_runtime_actions_and_vmx_content_reads() -> None: + bash_text = BASH_SCRIPT.read_text() + ps_text = PS_SCRIPT.read_text() + + assert "BatchMode=yes" in bash_text + assert "PreferredAuthentications=publickey" in bash_text + assert "PubkeyAuthentication=yes" in bash_text + assert "PasswordAuthentication=no" in bash_text + assert "KbdInteractiveAuthentication=no" in bash_text + assert "NumberOfPasswordPrompts=0" in bash_text + assert "in_memory_stdin_scriptblock" in bash_text + assert "[Console]::In.ReadToEnd()" in bash_text + assert "Get-ChildItem" in ps_text + assert "Test-Path" in ps_text + for text in [bash_text, ps_text]: + for forbidden in [ + "sshpass", + "PasswordAuthentication=yes", + "KbdInteractiveAuthentication=yes", + "net use", + "shutdown", + "Restart-Computer", + "Start-VM", + "vmrun start", + "Set-ItemProperty", + "Register-ScheduledTask", + "Set-Content", + "Copy-Item", + "Move-Item", + "Remove-Item", + "Get-Content", + "Select-String", + ]: + assert forbidden not in text + + +def test_check_mode_reports_auth_probe_without_remote_locator(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + port="${!#}" + if [[ "$port" == "22" || "$port" == "3389" ]]; then + exit 0 + fi + exit 1 + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + printf '%s\n' 'AWOOOI_WINDOWS99_SSH_READY' + exit 0 + """, + ) + + result = _run_locator(fake_bin, "--check", "--max-auth-users", "1") + + assert result.returncode == 0 + values = _key_values(result.stdout) + assert values["schema_version"] == "windows99_vmx_source_locator_collector_v1" + assert values["dry_run"] == "true" + assert values["target_host_alias"] == "99" + assert values["target_vm_alias"] == "111" + assert values["ssh_batchmode_auth_ready"] == "1" + assert values["remote_locator_attempted"] == "0" + assert values["locator_collection_status"] == "ready_ssh_batchmode_auth_probe_only" + assert values["secret_value_read"] == "false" + assert values["password_prompt_allowed"] == "false" + assert values["remote_write_performed"] == "false" + assert values["host_reboot_performed"] == "false" + assert values["vm_power_change_performed"] == "false" + assert values["vmx_file_content_read"] == "false" + assert values["raw_path_output"] == "false" + + +def test_collect_mode_blocks_without_publickey_auth(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + [[ "${!#}" == "22" ]] + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + exit 255 + """, + ) + + result = _run_locator(fake_bin, "--collect", "--max-auth-users", "1") + + assert result.returncode == 75 + values = _key_values(result.stdout) + assert values["ssh_batchmode_auth_ready"] == "0" + assert values["remote_locator_attempted"] == "0" + assert values["locator_collection_status"] == "blocked_ssh_publickey_auth_missing" + assert values["secret_value_read"] == "false" + assert values["remote_write_performed"] == "false" + + +def test_collect_mode_accepts_valid_remote_locator_stdout(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + [[ "${!#}" == "22" ]] + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + joined="$*" + if [[ "$joined" == *"echo AWOOOI_WINDOWS99_SSH_READY"* ]]; then + printf '%s\n' 'AWOOOI_WINDOWS99_SSH_READY' + exit 0 + fi + cat >/dev/null + printf '%s\n' 'EXPECTED_VMX alias=111 index=1 present=0 fingerprint=abc' + printf '%s\n' 'AWOOOI_WINDOWS99_VMX_SOURCE_LOCATOR=1' + printf '%s\n' 'schema_version=windows99_vmx_source_locator_v1' + printf '%s\n' 'MODE=Check' + printf '%s\n' 'target_host_alias=99' + printf '%s\n' 'target_vm_alias=111' + printf '%s\n' 'expected_vmx_path_count=1' + printf '%s\n' 'expected_vmx_path_present_count=0' + printf '%s\n' 'candidate_source_count=0' + printf '%s\n' 'source_locator_status=blocked_missing_expected_vmx_source_no_candidate_found' + printf '%s\n' 'file_content_read=false' + printf '%s\n' 'remote_write_performed=false' + exit 0 + """, + ) + + result = _run_locator(fake_bin, "--collect", "--max-auth-users", "1") + + assert result.returncode == 0 + values = _key_values(result.stdout) + assert values["dry_run"] == "false" + assert values["remote_locator_attempted"] == "1" + assert values["remote_locator_exit_status"] == "0" + assert values["locator_collection_status"] == ( + "collected_windows99_vmx_source_locator_stdout" + ) + assert "remote_locator_output_begin" in result.stdout + assert "source_locator_status=blocked_missing_expected_vmx_source_no_candidate_found" in result.stdout + assert "remote_locator_output_end" in result.stdout diff --git a/scripts/reboot-recovery/windows99-vmx-source-locator.ps1 b/scripts/reboot-recovery/windows99-vmx-source-locator.ps1 new file mode 100644 index 000000000..0cb4fa6e7 --- /dev/null +++ b/scripts/reboot-recovery/windows99-vmx-source-locator.ps1 @@ -0,0 +1,136 @@ +param( + [ValidateSet("Check")] + [string]$Mode = "Check", + [string]$TargetAlias = "111", + [string[]]$ExpectedVmxPaths = @( + "D:\Documents\Virtual Machines\192.168.0.111_Ubuntu_64-bit\192.168.0.111_Ubuntu_64-bit.vmx" + ), + [string[]]$DiscoveryRoot = @( + "D:\Documents\Virtual Machines", + "D:\Downloads", + "D:\VMs", + "E:\VMs", + "C:\VMs", + "C:\Users\Public\Documents\Virtual Machines" + ), + [int]$MaxCandidateCount = 20 +) + +$ErrorActionPreference = "Stop" + +if ($MaxCandidateCount -lt 1) { + $MaxCandidateCount = 20 +} + +function Get-StringFingerprint { + param([string]$Value) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant()) + return (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha.Dispose() + } +} + +function Write-BoolLine { + param( + [string]$Name, + [bool]$Value + ) + + Write-Output "$Name=$([int]$Value)" +} + +$normalizedExpected = @( + $ExpectedVmxPaths | + Where-Object { $_ -and $_.Trim() } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique +) +$normalizedRoots = @( + $DiscoveryRoot | + Where-Object { $_ -and $_.Trim() } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique +) + +$expectedPresentCount = 0 +$expectedIndex = 0 +foreach ($path in $normalizedExpected) { + $expectedIndex += 1 + $present = Test-Path -LiteralPath $path + if ($present) { + $expectedPresentCount += 1 + } + Write-Output "EXPECTED_VMX alias=$TargetAlias index=$expectedIndex present=$([int]$present) fingerprint=$(Get-StringFingerprint -Value $path)" +} + +$rootPresentCount = 0 +$rootIndex = 0 +$candidateFingerprints = New-Object System.Collections.Generic.List[string] +$aliasPattern = "(^|[\\._\-\s])$([regex]::Escape($TargetAlias))([\\._\-\s]|$)" + +foreach ($root in $normalizedRoots) { + $rootIndex += 1 + $rootPresent = Test-Path -LiteralPath $root + if ($rootPresent) { + $rootPresentCount += 1 + } + Write-Output "DISCOVERY_ROOT index=$rootIndex present=$([int]$rootPresent) fingerprint=$(Get-StringFingerprint -Value $root)" + if (-not $rootPresent) { + continue + } + + try { + $matches = Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*.vmx" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match $aliasPattern } | + Select-Object -First $MaxCandidateCount + foreach ($match in @($matches)) { + if ($candidateFingerprints.Count -ge $MaxCandidateCount) { + break + } + $candidateFingerprints.Add((Get-StringFingerprint -Value $match.FullName)) + } + } catch { + Write-Output "DISCOVERY_ROOT_ERROR fingerprint=$(Get-StringFingerprint -Value $root) error_type=$($_.Exception.GetType().Name)" + } +} + +$candidateFingerprintValues = @($candidateFingerprints | Select-Object -Unique) +$candidateCount = $candidateFingerprintValues.Count +$candidateStatus = "blocked_missing_expected_vmx_source_no_candidate_found" +$safeNextStep = "provide_existing_vmx_source_or_backup_path_then_rerun_locator_check_mode_no_write" +if ($expectedPresentCount -gt 0) { + $candidateStatus = "ready_expected_vmx_source_present" + $safeNextStep = "rerun_no_secret_vmware_verify_collector_and_reboot_slo_scorecard" +} elseif ($candidateCount -gt 0) { + $candidateStatus = "candidate_found_for_controlled_relink_check_mode" + $safeNextStep = "record_scoped_relink_dry_run_and_rollback_then_apply_existing_vmx_source_copy_or_link" +} + +Write-Output "AWOOOI_WINDOWS99_VMX_SOURCE_LOCATOR=1" +Write-Output "schema_version=windows99_vmx_source_locator_v1" +Write-Output "MODE=$Mode" +Write-Output "target_host_alias=99" +Write-Output "target_vm_alias=$TargetAlias" +Write-Output "expected_vmx_path_count=$($normalizedExpected.Count)" +Write-Output "expected_vmx_path_present_count=$expectedPresentCount" +Write-Output "discovery_root_count=$($normalizedRoots.Count)" +Write-Output "discovery_root_present_count=$rootPresentCount" +Write-Output "candidate_source_count=$candidateCount" +Write-Output "candidate_source_fingerprint_count=$candidateCount" +Write-Output "candidate_source_fingerprints=$($candidateFingerprintValues -join ',')" +Write-Output "source_locator_status=$candidateStatus" +Write-Output "safe_next_step=$safeNextStep" +Write-Output "raw_path_output=false" +Write-Output "path_fingerprint_only=true" +Write-Output "file_content_read=false" +Write-Output "secret_value_read=false" +Write-Output "password_prompt_allowed=false" +Write-Output "remote_write_performed=false" +Write-Output "host_reboot_performed=false" +Write-Output "vm_power_change_performed=false" +Write-Output "windows_registry_apply_performed=false" +Write-Output "scheduled_task_write_performed=false"