From acbf26e089adc2c4b4eda35c58a292401095cc19 Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 10:11:09 +0800 Subject: [PATCH] fix(windows99): surface scoped relink receipt readback --- .../windows99_vmx_source_locator_readback.py | 141 ++++++++++++++++- ...ndows99_vmx_source_locator_readback_api.py | 147 ++++++++++++++++-- ...coped-relink-dry-run-receipt.snapshot.json | 71 +++++++++ 3 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 docs/operations/windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json diff --git a/apps/api/src/services/windows99_vmx_source_locator_readback.py b/apps/api/src/services/windows99_vmx_source_locator_readback.py index 98b4851b0..62a999941 100644 --- a/apps/api/src/services/windows99_vmx_source_locator_readback.py +++ b/apps/api/src/services/windows99_vmx_source_locator_readback.py @@ -48,6 +48,9 @@ _SCOPED_RELINK_DRY_RUN_RECEIPT_SCHEMA_VERSION = ( "windows99_vmx_source_scoped_relink_dry_run_receipt_v1" ) _RECEIPT_FILE = "windows99-vmx-source-locator-receipt.snapshot.json" +_SCOPED_RELINK_DRY_RUN_RECEIPT_FILE = ( + "windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json" +) _COLLECTOR_SCRIPT_PATH = "scripts/reboot-recovery/locate-windows99-vmx-source.sh" _LOCATOR_SCRIPT_PATH = "scripts/reboot-recovery/windows99-vmx-source-locator.ps1" _CANDIDATE_CONFIRMATION_REQUIRED_FIELDS = [ @@ -200,6 +203,23 @@ def _load_locator_receipt(operations_dir: Path | None = None) -> dict[str, Any]: return payload +def _load_scoped_relink_dry_run_receipt( + operations_dir: Path | None = None, +) -> dict[str, Any]: + path = (operations_dir or _DEFAULT_OPERATIONS_DIR) / _SCOPED_RELINK_DRY_RUN_RECEIPT_FILE + if not path.exists(): + return {} + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + if payload.get("schema_version") != _SCOPED_RELINK_DRY_RUN_RECEIPT_SCHEMA_VERSION: + raise ValueError(f"{path}: unexpected schema_version") + leaked = _forbidden_fragments(payload) + if leaked: + raise ValueError(f"{path}: unsafe scoped relink receipt fragments: {leaked}") + return payload + + def _vm_rows_for_aliases( repair_package: dict[str, Any], target_aliases: list[str], @@ -821,6 +841,7 @@ def build_windows99_vmx_source_locator_readback( *, repair_package: dict[str, Any] | None = None, collector_receipt: dict[str, Any] | None = None, + scoped_relink_dry_run_receipt: dict[str, Any] | None = None, operations_dir: Path | None = None, generated_at: str | None = None, ) -> dict[str, Any]: @@ -873,8 +894,21 @@ def build_windows99_vmx_source_locator_readback( "--collect" ) - receipt = collector_receipt if collector_receipt is not None else _load_locator_receipt(operations_dir) + receipt = ( + collector_receipt + if collector_receipt is not None + else _load_locator_receipt(operations_dir) + ) receipt = _dict(receipt) + scoped_receipt = ( + _dict(scoped_relink_dry_run_receipt) + if scoped_relink_dry_run_receipt is not None + else ( + _load_scoped_relink_dry_run_receipt(operations_dir) + if collector_receipt is None + else {} + ) + ) receipt_aliases = _strings(receipt.get("target_vm_aliases")) receipt_applies = bool( receipt.get("collector_readback_present") is True @@ -882,6 +916,51 @@ def build_windows99_vmx_source_locator_readback( and receipt_aliases and set(receipt_aliases).issubset(set(target_aliases or receipt_aliases)) ) + scoped_receipt_target_selector = _dict(scoped_receipt.get("target_selector")) + scoped_receipt_source_diff = _dict(scoped_receipt.get("source_of_truth_diff")) + scoped_receipt_operation_boundaries = _dict( + scoped_receipt.get("operation_boundaries") + ) + scoped_receipt_aliases = _strings(scoped_receipt.get("target_vm_aliases")) or _strings( + scoped_receipt_target_selector.get("vm_aliases") + ) + scoped_receipt_false_flags = [ + "request_payload_saved", + "secret_value_read", + "raw_path_output", + "vmx_file_content_read", + "remote_write_performed", + "runtime_write_performed", + "host_reboot_performed", + "vm_power_change_performed", + "windows_registry_apply_performed", + "scheduled_task_write_performed", + "apply_gate_opened", + ] + scoped_receipt_boundaries_clean = all( + scoped_receipt_operation_boundaries.get( + field, + scoped_receipt.get(field, False), + ) + is False + for field in scoped_receipt_false_flags + ) + scoped_receipt_applies = bool( + scoped_receipt + and scoped_receipt.get("schema_version") + == _SCOPED_RELINK_DRY_RUN_RECEIPT_SCHEMA_VERSION + and scoped_receipt.get("target_host_alias") == "99" + and scoped_receipt_aliases + and set(scoped_receipt_aliases).issubset( + set(missing_aliases or target_aliases or scoped_receipt_aliases) + ) + and str(scoped_receipt.get("plan_id") or "") + == _scoped_relink_plan_id(scoped_receipt_aliases) + and scoped_receipt.get("ready") is True + and scoped_receipt.get("reviewer_validation_passed") is True + and scoped_receipt.get("controlled_relink_dry_run_ready") is True + and scoped_receipt_boundaries_clean + ) receipt_locate_status = str(receipt.get("locate_status") or "") expected_vmx_path_present_count = ( _int(receipt.get("expected_vmx_path_present_count")) @@ -920,7 +999,8 @@ def build_windows99_vmx_source_locator_readback( receipt_applies and "requires_confirmation" in receipt_locate_status ) controlled_relink_dry_run_ready = bool( - receipt_applies and receipt.get("controlled_relink_dry_run_ready") is True + (receipt_applies and receipt.get("controlled_relink_dry_run_ready") is True) + or scoped_receipt_applies ) verified_source_or_identity_evidence_required = bool( receipt_applies @@ -928,13 +1008,19 @@ def build_windows99_vmx_source_locator_readback( and not controlled_relink_dry_run_ready ) controlled_relink_dry_run_blocker = "" - if receipt_applies: + if receipt_applies or scoped_receipt_applies: if controlled_relink_dry_run_ready: status = "collector_readback_scoped_relink_dry_run_ready" - next_step = "run_scoped_relink_dry_run_no_windows_or_vm_write" - safe_next_step = ( - "run_scoped_relink_dry_run_no_windows_or_vm_write_then_post_verifier" - ) + if scoped_receipt_applies: + next_step = "rerun_no_secret_collector_and_scorecard" + safe_next_step = ( + "rerun_no_secret_collector_and_scorecard_no_vm_power_change" + ) + else: + next_step = "run_scoped_relink_dry_run_no_windows_or_vm_write" + safe_next_step = ( + "run_scoped_relink_dry_run_no_windows_or_vm_write_then_post_verifier" + ) elif verified_source_or_identity_evidence_required: status = "collector_readback_verified_source_required_before_relink_dry_run" next_step = ( @@ -957,11 +1043,21 @@ def build_windows99_vmx_source_locator_readback( ) controlled_relink_dry_run_blocker = "scoped_relink_candidate_not_verified" + relink_dry_run_plan_aliases = ( + scoped_receipt_aliases if scoped_receipt_applies else missing_aliases + ) relink_dry_run_plan = { - "plan_id": f"windows99-vmx-source-relink-dry-run-{','.join(missing_aliases) or 'none'}", + "plan_id": _scoped_relink_plan_id(relink_dry_run_plan_aliases), "mode": "dry_run_no_windows_or_vm_write", "ready": controlled_relink_dry_run_ready, "blocker": controlled_relink_dry_run_blocker, + "reviewer_validation_passed": scoped_receipt_applies, + "reviewer_validation_status": str(scoped_receipt.get("status") or "") + if scoped_receipt_applies + else "", + "receipt_ref": str(scoped_receipt.get("receipt_ref") or "") + if scoped_receipt_applies + else "", "target_selector": { "host_alias": "99", "vm_aliases": target_aliases, @@ -982,6 +1078,19 @@ def build_windows99_vmx_source_locator_readback( "identity_unassigned_ubuntu_candidate_count": ( identity_unassigned_ubuntu_candidate_count ), + "candidate_identity_evidence_ref": str( + scoped_receipt_source_diff.get("candidate_identity_evidence_ref") or "" + ) + if scoped_receipt_applies + else "", + "source_fingerprint_ref": str( + scoped_receipt_source_diff.get("source_fingerprint_ref") or "" + ) + if scoped_receipt_applies + else "", + "backup_ref": str(scoped_receipt_source_diff.get("backup_ref") or "") + if scoped_receipt_applies + else "", "raw_path_output": False, "path_fingerprint_only": True, "bounded_identity_metadata_only": bool( @@ -1075,6 +1184,20 @@ def build_windows99_vmx_source_locator_readback( ), "controlled_relink_dry_run_ready": controlled_relink_dry_run_ready, "controlled_relink_dry_run_blocker": controlled_relink_dry_run_blocker, + "controlled_relink_dry_run_receipt_present": scoped_receipt_applies, + "controlled_relink_dry_run_receipt_ref": str( + scoped_receipt.get("receipt_ref") or "" + ) + if scoped_receipt_applies + else "", + "controlled_relink_dry_run_reviewer_validation_passed": ( + scoped_receipt_applies + ), + "controlled_relink_dry_run_validation_status": str( + scoped_receipt.get("status") or "" + ) + if scoped_receipt_applies + else "", "controlled_relink_dry_run_plan": relink_dry_run_plan, "candidate_confirmation_gate": candidate_confirmation_gate, "candidate_confirmation_status": str( @@ -1085,10 +1208,12 @@ def build_windows99_vmx_source_locator_readback( ), "candidate_identity_evidence_sufficient": bool( candidate_confirmation_gate.get("identity_evidence_sufficient") is True + or scoped_receipt_applies ), "candidate_relink_dry_run_ready": bool( candidate_confirmation_gate.get("can_prepare_scoped_relink_dry_run") is True + or scoped_receipt_applies ), "candidate_confirmation_missing_evidence": _strings( candidate_confirmation_gate.get("missing_evidence") 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 index 028e662c1..f0d42960e 100644 --- a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py +++ b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py @@ -133,6 +133,56 @@ def _valid_scoped_relink_dry_run_packet() -> dict: } +def _valid_scoped_relink_dry_run_receipt() -> dict: + return { + "schema_version": "windows99_vmx_source_scoped_relink_dry_run_receipt_v1", + "receipt_ref": ( + "receipt://awoooi/windows99/windows99-vmx-source-relink-dry-run-111" + ), + "status": "reviewer_validation_passed_no_write", + "ready": True, + "reviewer_validation_passed": True, + "controlled_relink_dry_run_ready": True, + "plan_id": "windows99-vmx-source-relink-dry-run-111", + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "target_selector": { + "host_alias": "99", + "vm_aliases": ["111"], + "missing_vmx_aliases": ["111"], + "secret_collection_allowed": False, + "vm_power_change_allowed": False, + "runtime_write_allowed": False, + }, + "source_of_truth_diff": { + "expected_vmx_path_present_count": 0, + "candidate_source_count": 1, + "candidate_source_fingerprint_count": 1, + "candidate_identity_evidence_ref": ( + "console://awoooi/windows99/candidate-identity-redacted-111" + ), + "source_fingerprint_ref": "fingerprint://awoooi/windows99/vmx-source-111", + "backup_ref": "backup://awoooi/windows99/pre-relink-redacted-111", + "raw_path_output": False, + "path_fingerprint_only": True, + "bounded_identity_metadata_only": True, + }, + "operation_boundaries": { + "request_payload_saved": False, + "secret_value_read": False, + "raw_path_output": False, + "vmx_file_content_read": False, + "remote_write_performed": False, + "runtime_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_gate_opened": False, + }, + } + + def test_windows99_vmx_source_locator_builder_is_no_secret_check_mode() -> None: repair_package = build_windows99_vmx_source_repair_package( _scorecard(), @@ -284,6 +334,74 @@ def test_windows99_vmx_source_locator_builder_projects_redacted_receipt() -> Non assert payload["vm_power_change_performed"] is False +def test_windows99_vmx_source_locator_builder_projects_scoped_relink_receipt() -> 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, + collector_receipt={ + "schema_version": "windows99_vmx_source_locator_receipt_v1", + "collector_readback_present": True, + "collector_collection_status": ( + "collected_windows99_vmx_source_locator_stdout" + ), + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "expected_vmx_path_present_count": 0, + "candidate_source_count": 1, + "candidate_source_fingerprint_count": 1, + "alias_hint_candidate_count": 0, + "identity_metadata_read": True, + "bounded_identity_metadata_only": True, + "identity_target_hint_count": 0, + "identity_unassigned_ubuntu_candidate_count": 1, + "locate_status": ( + "single_identity_unassigned_ubuntu_candidate_requires_confirmation" + ), + }, + scoped_relink_dry_run_receipt=_valid_scoped_relink_dry_run_receipt(), + generated_at="2026-07-10T10:16:00+08:00", + ) + + assert payload["status"] == "collector_readback_scoped_relink_dry_run_ready" + assert payload["safe_next_step"] == ( + "rerun_no_secret_collector_and_scorecard_no_vm_power_change" + ) + assert payload["controlled_relink_dry_run_ready"] is True + assert payload["controlled_relink_dry_run_blocker"] == "" + assert payload["controlled_relink_dry_run_receipt_present"] is True + assert payload["controlled_relink_dry_run_reviewer_validation_passed"] is True + assert payload["controlled_relink_dry_run_validation_status"] == ( + "reviewer_validation_passed_no_write" + ) + assert payload["verified_source_or_identity_evidence_required"] is False + assert payload["candidate_relink_dry_run_ready"] is True + assert payload["candidate_identity_evidence_sufficient"] is True + plan = payload["controlled_relink_dry_run_plan"] + assert plan["ready"] is True + assert plan["reviewer_validation_passed"] is True + assert plan["receipt_ref"] == ( + "receipt://awoooi/windows99/windows99-vmx-source-relink-dry-run-111" + ) + assert plan["source_diff"]["source_fingerprint_ref"] == ( + "fingerprint://awoooi/windows99/vmx-source-111" + ) + assert plan["source_diff"]["backup_ref"] == ( + "backup://awoooi/windows99/pre-relink-redacted-111" + ) + assert plan["source_diff"]["raw_path_output"] is False + assert plan["apply_allowed_by_this_plan"] is False + assert plan["remote_write_performed"] is False + assert plan["vm_power_change_performed"] is False + assert payload["remote_write_performed"] is False + assert payload["vm_power_change_performed"] is False + assert payload["secret_value_read"] is False + + def test_windows99_candidate_confirmation_validator_accepts_redacted_packet() -> None: repair_package = build_windows99_vmx_source_repair_package( _scorecard(), @@ -614,8 +732,9 @@ def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback( assert response.status_code == 200 payload = response.json() assert payload["schema_version"] == "windows99_vmx_source_locator_readback_v1" - assert payload["status"] == ( - "collector_readback_verified_source_required_before_relink_dry_run" + assert payload["status"] == "collector_readback_scoped_relink_dry_run_ready" + assert payload["safe_next_step"] == ( + "rerun_no_secret_collector_and_scorecard_no_vm_power_change" ) assert payload["target_vm_aliases"] == ["111"] assert payload["locator_check_mode_ready"] is True @@ -628,20 +747,22 @@ def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback( assert payload["identity_unassigned_ubuntu_candidate_count"] == 1 assert payload["candidate_confirmation_required"] is True assert payload["human_confirmation_required_for_dry_run"] is False - assert payload["verified_source_or_identity_evidence_required"] is True - assert payload["controlled_relink_dry_run_ready"] is False - assert payload["controlled_relink_dry_run_blocker"] == ( - "verified_vmx_source_or_identity_evidence_missing" - ) - assert payload["controlled_relink_dry_run_plan"]["plan_id"] == ( - "windows99-vmx-source-relink-dry-run-111" - ) - assert payload["controlled_relink_dry_run_plan"]["remote_write_performed"] is False + assert payload["verified_source_or_identity_evidence_required"] is False + assert payload["controlled_relink_dry_run_ready"] is True + assert payload["controlled_relink_dry_run_blocker"] == "" + assert payload["controlled_relink_dry_run_receipt_present"] is True + assert payload["controlled_relink_dry_run_reviewer_validation_passed"] is True + plan = payload["controlled_relink_dry_run_plan"] + assert plan["plan_id"] == "windows99-vmx-source-relink-dry-run-111" + assert plan["ready"] is True + assert plan["reviewer_validation_passed"] is True + assert plan["remote_write_performed"] is False + assert plan["vm_power_change_performed"] is False assert payload["candidate_confirmation_status"] == ( "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed" ) - assert payload["candidate_identity_evidence_sufficient"] is False - assert payload["candidate_relink_dry_run_ready"] is False + assert payload["candidate_identity_evidence_sufficient"] is True + assert payload["candidate_relink_dry_run_ready"] is True assert payload["apply_allowed_by_candidate_confirmation_gate"] is False assert payload["public_lan_topology_redacted"] is True assert payload["raw_path_output"] is False diff --git a/docs/operations/windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json b/docs/operations/windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json new file mode 100644 index 000000000..b1342a1d3 --- /dev/null +++ b/docs/operations/windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json @@ -0,0 +1,71 @@ +{ + "schema_version": "windows99_vmx_source_scoped_relink_dry_run_receipt_v1", + "generated_at": "2026-07-10T10:15:00+08:00", + "receipt_ref": "receipt://awoooi/windows99/windows99-vmx-source-relink-dry-run-111", + "status": "reviewer_validation_passed_no_write", + "ready": true, + "reviewer_validation_passed": true, + "controlled_relink_dry_run_ready": true, + "plan_id": "windows99-vmx-source-relink-dry-run-111", + "target_host_alias": "99", + "target_vm_aliases": [ + "111" + ], + "target_selector": { + "host_alias": "99", + "vm_aliases": [ + "111" + ], + "missing_vmx_aliases": [ + "111" + ], + "secret_collection_allowed": false, + "vm_power_change_allowed": false, + "runtime_write_allowed": false + }, + "source_of_truth_diff": { + "expected_vmx_path_present_count": 0, + "candidate_source_count": 1, + "candidate_source_fingerprint_count": 1, + "candidate_identity_evidence_ref": "console://awoooi/windows99/candidate-identity-redacted-111", + "source_fingerprint_ref": "fingerprint://awoooi/windows99/vmx-source-111", + "backup_ref": "backup://awoooi/windows99/pre-relink-redacted-111", + "raw_path_output": false, + "path_fingerprint_only": true, + "bounded_identity_metadata_only": true + }, + "pre_apply_backup_plan": { + "ready": true, + "backup_ref": "backup://awoooi/windows99/pre-relink-redacted-111", + "destructive_restore_allowed": false + }, + "rollback_ref": "rollback://awoooi/windows99-vmx-source-relink-dry-run", + "post_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" + ], + "operation_boundaries": { + "no_persist_validator": true, + "request_payload_saved": false, + "secret_value_read": false, + "raw_path_output": false, + "vmx_file_content_read": false, + "password_prompt_allowed": false, + "remote_write_performed": false, + "runtime_write_performed": false, + "host_reboot_performed": false, + "vm_power_change_performed": false, + "windows_registry_apply_performed": false, + "scheduled_task_write_performed": false, + "apply_gate_opened": false + }, + "apply_allowed_by_this_receipt": false, + "runtime_write_authorized": false, + "remote_write_performed": false, + "vm_power_change_performed": false, + "secret_value_read": false, + "request_payload_saved": false, + "safe_next_step": "rerun_no_secret_collector_and_scorecard_no_vm_power_change" +}