vmware-aiops 2.4.1__tar.gz → 2.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.claude-plugin/plugin.json +1 -1
  2. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.mcp.json +1 -1
  3. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/PKG-INFO +3 -3
  4. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/RELEASE_NOTES.md +47 -0
  5. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/openclaw.plugin.json +1 -1
  6. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/package.json +1 -1
  7. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/pyproject.toml +3 -3
  8. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/server.json +2 -2
  9. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/SKILL.md +2 -2
  10. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/setup-guide.md +5 -5
  11. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_code_review_fixes.py +3 -3
  12. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_daemon_calls_are_audited.py +8 -8
  13. vmware_aiops-2.4.2/tests/eval/regression/test_image_registry_cross_process.py +134 -0
  14. vmware_aiops-2.4.2/tests/eval/regression/test_image_registry_legacy_rows.py +150 -0
  15. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_login_without_vsphere_role.py +56 -0
  16. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_set_ttl_safety.py +12 -0
  17. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_cluster_ttl_plan_gate.py +2 -2
  18. vmware_aiops-2.4.2/tests/test_filelock.py +81 -0
  19. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_gate_review_fixes.py +2 -2
  20. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_host_network_mgmt.py +10 -0
  21. vmware_aiops-2.4.2/tests/test_migrate_compatibility_check.py +405 -0
  22. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_gate_narrow_fixes.py +3 -3
  23. vmware_aiops-2.4.2/tests/test_power_off_and_preview_policy.py +581 -0
  24. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/uv.lock +7 -7
  25. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/__init__.py +1 -1
  26. vmware_aiops-2.4.2/vmware_aiops/_filelock.py +94 -0
  27. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/cluster.py +7 -1
  28. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/deploy.py +3 -0
  29. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/vm.py +38 -6
  30. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/connection.py +39 -0
  31. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/datastore.py +2 -0
  32. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/guest.py +20 -7
  33. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/plan.py +9 -2
  34. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/ttl.py +31 -8
  35. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/datastore_browser.py +69 -17
  36. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/host_network_mgmt.py +16 -0
  37. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/plan_executor.py +28 -6
  38. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/plan_gate.py +48 -2
  39. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/step_policy.py +74 -3
  40. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/ttl.py +19 -3
  41. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_gate.py +120 -0
  42. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_lifecycle.py +28 -5
  43. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/policy_roles.py +2 -1
  44. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/scheduler.py +46 -2
  45. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.clawhubignore +0 -0
  46. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.env.example +0 -0
  47. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  48. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  49. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  50. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  51. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/dependabot.yml +0 -0
  52. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.gitignore +0 -0
  53. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/Dockerfile +0 -0
  54. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/LICENSE +0 -0
  55. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/README-CN.md +0 -0
  56. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/README.md +0 -0
  57. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/SECURITY.md +0 -0
  58. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/config.example.yaml +0 -0
  59. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docker-compose.yml +0 -0
  60. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/continue.md +0 -0
  61. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/cursor.md +0 -0
  62. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/goose.md +0 -0
  63. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/localcowork.md +0 -0
  64. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/mcp-agent.md +0 -0
  65. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/vscode-copilot.md +0 -0
  66. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/plans/2026-03-09-plan-apply-design.md +0 -0
  67. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/deploy.yaml +0 -0
  68. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/README.md +0 -0
  69. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/claude-code.json +0 -0
  70. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/continue.yaml +0 -0
  71. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/cursor.json +0 -0
  72. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/goose.json +0 -0
  73. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/localcowork.json +0 -0
  74. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
  75. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/vscode-copilot.json +0 -0
  76. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/ollama-local-setup.md +0 -0
  77. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/evals/evals.json +0 -0
  78. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/agent-guardrails.md +0 -0
  79. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/capabilities.md +0 -0
  80. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/cli-reference.md +0 -0
  81. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/investigation-protocol.md +0 -0
  82. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/smithery.yaml +0 -0
  83. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/__init__.py +0 -0
  84. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/conftest.py +0 -0
  85. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/__init__.py +0 -0
  86. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/__init__.py +0 -0
  87. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_family.py +0 -0
  88. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_scores.json +0 -0
  89. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_scoring.py +0 -0
  90. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_skill.py +0 -0
  91. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/conftest.py +0 -0
  92. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_entity_reachability.py +0 -0
  93. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_error_actionability.py +0 -0
  94. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
  95. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  96. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/__init__.py +0 -0
  97. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/_diag_fakes.py +0 -0
  98. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/_pc_fakes.py +0 -0
  99. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_aiops_specific.py +0 -0
  100. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_alarm_health_scale_n1.py +0 -0
  101. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
  102. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_browse_ds_path_join.py +0 -0
  103. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_browse_sees_every_file.py +0 -0
  104. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_capability_grader.py +0 -0
  105. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cli_destructive_guards.py +0 -0
  106. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
  107. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cluster_health_summary_delegation.py +0 -0
  108. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_config_path_resolution_is_shared.py +0 -0
  109. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_daemon_host_log_cycles.py +0 -0
  110. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_deploy_correctness.py +0 -0
  111. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_deploy_failures_are_failures.py +0 -0
  112. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
  113. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_documented_gates_match_the_registry.py +0 -0
  114. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_enum_params_match_implementation.py +0 -0
  115. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
  116. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_error_hints_fit_the_surface.py +0 -0
  117. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_event_sets_match_real_class_names.py +0 -0
  118. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_events_are_read_newest_first.py +0 -0
  119. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_guest_download_writes_the_local_disk.py +0 -0
  120. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_guest_username_is_explicit.py +0 -0
  121. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_host_log_scan_reads_real_logs.py +0 -0
  122. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_host_paths_protected.py +0 -0
  123. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_image_registry_per_target.py +0 -0
  124. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
  125. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_inventory_scale_issue31.py +0 -0
  126. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_linked_clone_failure_on_the_wire.py +0 -0
  127. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_list_envelope_conformance.py +0 -0
  128. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_list_plans_is_non_destructive.py +0 -0
  129. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_mcp_frame_marks_failures.py +0 -0
  130. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_ova_network_mapping.py +0 -0
  131. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_release_blockers.py +0 -0
  132. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_result_envelope.py +0 -0
  133. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
  134. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
  135. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_snapshot_async.py +0 -0
  136. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_text_io_declares_encoding.py +0 -0
  137. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_tool_annotations_match_behaviour.py +0 -0
  138. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_unreachable_host_vmks.py +0 -0
  139. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_v1_5_25_blockers.py +0 -0
  140. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
  141. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_write_round_low_findings.py +0 -0
  142. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_access_info.py +0 -0
  143. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_cluster_mgmt_sanitize.py +0 -0
  144. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_composite_tools_obey_policy.py +0 -0
  145. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_concurrent_local_state.py +0 -0
  146. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_config.py +0 -0
  147. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_drs_rules.py +0 -0
  148. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_guest_tools_gate.py +0 -0
  149. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_init_and_onboarding.py +0 -0
  150. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_network_mgmt.py +0 -0
  151. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_no_destructive_ops.py +0 -0
  152. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_rollback_final_fixes.py +0 -0
  153. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_steps_obey_policy.py +0 -0
  154. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_policy_roles.py +0 -0
  155. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_reset_alarm_gate.py +0 -0
  156. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_results_name_their_target.py +0 -0
  157. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_safe_error_passthrough.py +0 -0
  158. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_session_pool.py +0 -0
  159. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_stop_signal_runs_atexit.py +0 -0
  160. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_vm_delete_blast_radius.py +0 -0
  161. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_vm_tools_gate.py +0 -0
  162. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/_fsutil.py +0 -0
  163. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/access_requirements.py +0 -0
  164. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/__init__.py +0 -0
  165. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/_common.py +0 -0
  166. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/_root.py +0 -0
  167. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/access.py +0 -0
  168. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/alarm.py +0 -0
  169. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/doctor.py +0 -0
  170. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/hub.py +0 -0
  171. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/investigate.py +0 -0
  172. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/mcp_config.py +0 -0
  173. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/plan.py +0 -0
  174. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/scan.py +0 -0
  175. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/summary.py +0 -0
  176. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/config.py +0 -0
  177. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/doctor.py +0 -0
  178. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/init_wizard.py +0 -0
  179. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/__init__.py +0 -0
  180. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/__main__.py +0 -0
  181. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/_shared.py +0 -0
  182. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/server.py +0 -0
  183. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/__init__.py +0 -0
  184. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/access.py +0 -0
  185. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/alarm.py +0 -0
  186. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/cluster.py +0 -0
  187. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/deploy.py +0 -0
  188. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/network.py +0 -0
  189. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/summary.py +0 -0
  190. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/vm.py +0 -0
  191. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/__init__.py +0 -0
  192. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/audit.py +0 -0
  193. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/logger.py +0 -0
  194. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/webhook.py +0 -0
  195. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/__init__.py +0 -0
  196. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/_paging.py +0 -0
  197. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/alarm_mgmt.py +0 -0
  198. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/cluster_gate.py +0 -0
  199. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/cluster_mgmt.py +0 -0
  200. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/gate.py +0 -0
  201. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/guest_gate.py +0 -0
  202. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/guest_ops.py +0 -0
  203. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/health.py +0 -0
  204. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/inventory.py +0 -0
  205. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/iscsi_config.py +0 -0
  206. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/network_mgmt.py +0 -0
  207. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/ova_deploy.py +0 -0
  208. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/planner.py +0 -0
  209. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_delete_gate.py +0 -0
  210. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_deploy.py +0 -0
  211. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/policy_environment.py +0 -0
  212. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/__init__.py +0 -0
  213. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/alarm_scanner.py +0 -0
  214. {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/log_scanner.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "vmware-aiops",
4
4
  "displayName": "VMware AIops",
5
- "version": "2.4.1",
5
+ "version": "2.4.2",
6
6
  "description": "AI-powered VMware vCenter/ESXi VM lifecycle and deployment with 61 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "vmware-aiops==2.4.1",
8
+ "vmware-aiops==2.4.2",
9
9
  "vmware-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-aiops
3
- Version: 2.4.1
3
+ Version: 2.4.2
4
4
  Summary: VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool
5
5
  Project-URL: Homepage, https://github.com/vmware-skills/VMware-AIops
6
6
  Project-URL: Repository, https://github.com/vmware-skills/VMware-AIops
@@ -12,13 +12,13 @@ License-File: LICENSE
12
12
  Requires-Python: >=3.10
13
13
  Requires-Dist: apscheduler<4.0,>=3.10
14
14
  Requires-Dist: httpx<1.0,>=0.27
15
- Requires-Dist: mcp[cli]<2.0,>=1.28
15
+ Requires-Dist: mcp[cli]<2.0,>=1.30
16
16
  Requires-Dist: pyaml<27.0,>=24.0
17
17
  Requires-Dist: python-dotenv<2.0,>=1.0
18
18
  Requires-Dist: pyvmomi<10.0,>=8.0.3.0
19
19
  Requires-Dist: rich<16.0,>=13.0
20
20
  Requires-Dist: typer<1.0,>=0.12
21
- Requires-Dist: vmware-monitor<3.0,>=2.3.1
21
+ Requires-Dist: vmware-monitor<3.0,>=2.4.2
22
22
  Requires-Dist: vmware-policy<2.0,>=1.23.1
23
23
  Description-Content-Type: text/markdown
24
24
 
@@ -1,3 +1,50 @@
1
+ ## v2.4.2 — a login refused for lack of a role says so; an unreadable host is not called unreachable (2026-10-19)
2
+
3
+ Needs vmware-monitor 2.4.2 (now the floor). Found on a vCenter 8.0.3 lab with two restricted SSO accounts
4
+ (follow-up to AIops#47).
5
+
6
+ * **A login vCenter refuses with `NoPermission` gets the role error, not the raw fault.** For an account with no
7
+ permission anywhere, vCenter refuses the login itself (`System.View` on the root folder), so the post-login
8
+ no-role check never ran and `doctor` and every tool showed the raw fault with a connectivity hint. It now raises
9
+ the no-role error: the "no vSphere role / Global Permissions" remedy only for a vCenter target and `System.View`;
10
+ an ESXi target or another privilege gets a neutral text naming the (sanitized) privilege. A wrong password
11
+ (`InvalidLogin`) is unchanged. Same text as vmware-monitor.
12
+ * **vmk tools and `cluster info` no longer call an unreadable host unreachable.** When the account cannot read a
13
+ host's connection state, `list_host_vmks` rows and the vmk write tools' refusal say the account cannot read the
14
+ host (`reachable: false` means "not read"), instead of advising a reconnect. `cluster info` colours the state
15
+ yellow, not red.
16
+ * **From vmware-monitor 2.4.2 (through `get_cluster_health_summary` / `get_cross_vcenter_attention`):** a host this
17
+ account cannot read is counted `hosts_unreadable` with a warning issue `host_unreadable`, not ranked as a
18
+ critical `host_down`.
19
+
20
+ ## Unreleased — follow-ups to the 2.4.1 review
21
+
22
+ * **Previews show policy refusals.** `restore_vm_clean_slate`, `provision_vm_guest` and
23
+ `run_vm_guest_command_with_output` were refused by `rules.yaml` only at `confirm=True`; the preview now lists that
24
+ refusal in `blast_radius.blockers`, so the user sees it before agreeing.
25
+ * **A delete that powers the VM off first is checked as `power_off_vm` too:** the CLI `vm delete`, the TTL daemon,
26
+ and a plan rollback deleting the VM it created (checked when the VM is running or its state cannot be read). The
27
+ TTL daemon records one `delete_vm` row per change of outcome, as for a deny on `delete_vm`, and keeps the entry.
28
+ **Behaviour change for custom network roles:** a role that allows `set_vm_ttl` must also allow `power_off_vm`.
29
+ * **Image cache:** writes take a lock across processes (a CLI and an MCP scan at the same time lost one side's
30
+ rows); a scan keeps rows on datastores it failed to browse (its own or legacy ones) and drops this target's rows
31
+ on datastores that no longer exist. A cache that cannot be written no longer fails the scan: the images come back
32
+ with a `cache_note`, as in vmware-storage.
33
+ * **Each call is checked for what it will do, at the moment it does it.** `restore_vm_clean_slate` asks about
34
+ `power_off_vm` only when it actually powers the VM off; `provision_vm_guest` asks about the guest tools its step
35
+ types use (an exec-only provision is not refused by a deny on `upload_vm_guest_file`); a plan rollback asks about
36
+ the power-off after confirming it is the VM the plan created, just before powering it off.
37
+ * **`set_vm_ttl` is checked as the deletion it schedules:** the preview lists a deny on `delete_vm` (and on
38
+ `power_off_vm` for a running VM), and `confirm=True` refuses it, instead of scheduling a TTL the daemon can never
39
+ act on. The daemon still asks again at expiry.
40
+ * **`migrate_vm` previews run vCenter's own vMotion compatibility check** (`CheckMigrate_Task`, read-only): its
41
+ errors (CPU features, devices, networks, datastores) become blockers, with the CPU-feature remedy where it applies,
42
+ so a move vCenter would refuse is refused in the preview and before the CLI asks for confirmation; warnings are
43
+ shown and do not block. On a standalone ESXi host, or when the check cannot run, the preview says "not checked"
44
+ and does not block (vCenter checks again when the move starts). Plan migrate steps get the same check.
45
+ * The TTL store takes the same cross-process lock as the image cache (`set_vm_ttl` and the daemon run in separate
46
+ processes).
47
+
1
48
  ## v2.4.1 — fixes from the 2026-10-17 real-hardware read and write rounds
2
49
 
3
50
  * **Tools that run other tools' operations obey `rules.yaml` for those tools**, on the MCP and CLI surfaces.
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-aiops==2.4.1",
14
+ "vmware-aiops==2.4.2",
15
15
  "vmware-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-aiops",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "AI-powered VMware vCenter/ESXi VM lifecycle and deployment with 61 MCP tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmware-aiops"
7
- version = "2.4.1"
7
+ version = "2.4.2"
8
8
  description = "VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -18,12 +18,12 @@ dependencies = [
18
18
  "apscheduler>=3.10,<4.0",
19
19
  "httpx>=0.27,<1.0",
20
20
  "python-dotenv>=1.0,<2.0",
21
- "mcp[cli]>=1.28,<2.0",
21
+ "mcp[cli]>=1.30,<2.0",
22
22
  "vmware-policy>=1.23.1,<2.0",
23
23
  # Read-only cluster-health triage (summary + HTML snapshot) is owned by
24
24
  # vmware-monitor; AIops delegates to it so a conversation with the AIops
25
25
  # entry point can produce the same report without duplicating the code.
26
- "vmware-monitor>=2.3.1,<3.0", # attention status floor + role-less login (AIops#47)
26
+ "vmware-monitor>=2.4.2,<3.0", # unreadable host != down, login refused -> role error
27
27
  ]
28
28
 
29
29
 
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "2.4.1",
10
+ "version": "2.4.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-aiops",
15
- "version": "2.4.1",
15
+ "version": "2.4.2",
16
16
  "runtimeHint": "uvx",
17
17
  "packageArguments": [
18
18
  {
@@ -63,7 +63,7 @@ Read before connecting an agent. Per-tool inventory: `references/capabilities.md
63
63
  ## Quick Install
64
64
 
65
65
  ```bash
66
- uv tool install vmware-aiops==2.4.1
66
+ uv tool install vmware-aiops==2.4.2
67
67
  vmware-aiops doctor
68
68
  vmware-aiops hub status # see which family members are installed
69
69
  ```
@@ -303,7 +303,7 @@ The account has no vSphere role. Assign one (Read-Only or higher) on the vCenter
303
303
  ## Setup
304
304
 
305
305
  ```bash
306
- uv tool install vmware-aiops==2.4.1
306
+ uv tool install vmware-aiops==2.4.2
307
307
  mkdir -p ~/.vmware-aiops
308
308
  vmware-aiops init # generates config.yaml and .env templates
309
309
  chmod 600 ~/.vmware-aiops/.env
@@ -6,13 +6,13 @@ All install methods fetch from the same source: [github.com/vmware-skills/VMware
6
6
 
7
7
  ```bash
8
8
  # Via PyPI (recommended for version pinning)
9
- uv tool install vmware-aiops==2.4.1
9
+ uv tool install vmware-aiops==2.4.2
10
10
 
11
11
  # Via Skills.sh (fetches from GitHub)
12
- npx skills add vmware-skills/VMware-AIops#v2.4.1
12
+ npx skills add vmware-skills/VMware-AIops#v2.4.2
13
13
 
14
14
  # Via ClawHub (fetches from ClawHub registry snapshot of GitHub)
15
- clawhub install @zw008/vmware-aiops --version 2.4.1
15
+ clawhub install @zw008/vmware-aiops --version 2.4.2
16
16
  ```
17
17
 
18
18
  ### Claude Code
@@ -35,7 +35,7 @@ claude mcp add vmware-aiops -- vmware-aiops mcp
35
35
 
36
36
  ```bash
37
37
  # 1. Install from PyPI (source: github.com/vmware-skills/VMware-AIops)
38
- uv tool install vmware-aiops==2.4.1
38
+ uv tool install vmware-aiops==2.4.2
39
39
 
40
40
  # 2. Verify installation source
41
41
  vmware-aiops --version # confirms installed version
@@ -73,7 +73,7 @@ The `vmware-aiops` package installs a Python CLI binary and its dependencies (py
73
73
  ## Development Install
74
74
 
75
75
  ```bash
76
- git clone --branch v2.4.1 https://github.com/vmware-skills/VMware-AIops.git
76
+ git clone --branch v2.4.2 https://github.com/vmware-skills/VMware-AIops.git
77
77
  cd VMware-AIops
78
78
  uv venv && source .venv/bin/activate
79
79
  # --no-sources: pyproject's [tool.uv.sources] points vmware-monitor at a sibling
@@ -127,7 +127,7 @@ def test_ttl_entry_kept_on_transient_failure(monkeypatch: pytest.MonkeyPatch) ->
127
127
  monkeypatch.setattr(scheduler, "remove_entry", removed.append)
128
128
  monkeypatch.setattr(
129
129
  scheduler, "delete_vm",
130
- lambda si, name: (_ for _ in ()).throw(RuntimeError("vCenter task timeout")),
130
+ lambda si, name, **k: (_ for _ in ()).throw(RuntimeError("vCenter task timeout")),
131
131
  )
132
132
 
133
133
  scheduler._run_ttl_check(MagicMock())
@@ -143,7 +143,7 @@ def test_ttl_entry_removed_when_vm_already_gone(monkeypatch: pytest.MonkeyPatch)
143
143
  monkeypatch.setattr(scheduler, "remove_entry", removed.append)
144
144
  monkeypatch.setattr(
145
145
  scheduler, "delete_vm",
146
- lambda si, name: (_ for _ in ()).throw(VMNotFoundError("VM 'ttl-vm' not found")),
146
+ lambda si, name, **k: (_ for _ in ()).throw(VMNotFoundError("VM 'ttl-vm' not found")),
147
147
  )
148
148
 
149
149
  scheduler._run_ttl_check(MagicMock())
@@ -156,7 +156,7 @@ def test_ttl_entry_removed_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
156
156
  removed: list[str] = []
157
157
  monkeypatch.setattr(scheduler, "get_expired_entries", lambda: [_ttl_entry()])
158
158
  monkeypatch.setattr(scheduler, "remove_entry", removed.append)
159
- monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
159
+ monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
160
160
 
161
161
  scheduler._run_ttl_check(MagicMock())
162
162
  assert removed == ["ttl-vm"]
@@ -71,7 +71,7 @@ def ttl(monkeypatch):
71
71
 
72
72
  def test_a_ttl_delete_writes_one_vm_delete_row(ttl, monkeypatch):
73
73
  scheduler, removed = ttl
74
- monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
74
+ monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
75
75
 
76
76
  scheduler._run_ttl_check(MagicMock())
77
77
 
@@ -88,7 +88,7 @@ def test_a_ttl_delete_writes_one_vm_delete_row(ttl, monkeypatch):
88
88
  def test_a_failed_ttl_delete_is_recorded_as_error_and_the_entry_kept(ttl, monkeypatch):
89
89
  scheduler, removed = ttl
90
90
 
91
- def boom(si, name):
91
+ def boom(si, name, **k):
92
92
  raise RuntimeError("vCenter task timeout")
93
93
 
94
94
  monkeypatch.setattr(scheduler, "delete_vm", boom)
@@ -106,7 +106,7 @@ def test_a_ttl_vm_already_gone_is_recorded_and_the_stale_entry_dropped(ttl, monk
106
106
 
107
107
  scheduler, removed = ttl
108
108
 
109
- def gone(si, name):
109
+ def gone(si, name, **k):
110
110
  raise VMNotFoundError(f"VM '{name}' not found")
111
111
 
112
112
  monkeypatch.setattr(scheduler, "delete_vm", gone)
@@ -122,7 +122,7 @@ def test_a_ttl_vm_already_gone_is_recorded_and_the_stale_entry_dropped(ttl, monk
122
122
  def test_a_ttl_delete_refused_by_policy_never_runs(ttl, monkeypatch):
123
123
  scheduler, removed = ttl
124
124
  deleted: list[str] = []
125
- monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: deleted.append(name))
125
+ monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: deleted.append(name))
126
126
 
127
127
  def deny(skill, tool, params=None, *, risk_level="low", target=""):
128
128
  raise PolicyDenied(
@@ -144,7 +144,7 @@ def test_a_refused_ttl_entry_writes_one_row_per_outcome_not_one_per_minute(ttl,
144
144
  failing entry wrote a delete_vm row on every run — 1,440 a day per entry, on
145
145
  this lab whose rules.yaml denies delete_vm — burying real calls."""
146
146
  scheduler, removed = ttl
147
- monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: None)
147
+ monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: None)
148
148
 
149
149
  def deny(skill, tool, params=None, *, risk_level="low", target=""):
150
150
  raise PolicyDenied(
@@ -165,7 +165,7 @@ def test_a_failing_ttl_delete_writes_again_only_when_its_outcome_changes(ttl, mo
165
165
  scheduler, removed = ttl
166
166
  attempts: list[str] = []
167
167
 
168
- def flaky(si, name):
168
+ def flaky(si, name, **k):
169
169
  attempts.append(name)
170
170
  if len(attempts) <= 3:
171
171
  raise RuntimeError("vCenter task timeout")
@@ -315,7 +315,7 @@ def test_a_ttl_vm_whose_name_is_ambiguous_is_not_deleted_and_the_entry_dropped(t
315
315
 
316
316
  scheduler, removed = ttl
317
317
 
318
- def ambiguous(si, name):
318
+ def ambiguous(si, name, **k):
319
319
  raise AmbiguousVMError(f"2 VMs are named '{name}'.")
320
320
 
321
321
  monkeypatch.setattr(scheduler, "delete_vm", ambiguous)
@@ -332,7 +332,7 @@ def test_daemon_rows_say_daemon_not_mcp(ttl, monkeypatch):
332
332
  """HLD §16 D-14: a TTL delete made by the daemon is not an MCP call. Without
333
333
  an explicit surface the row read `mcp-stdio` — non-empty, and wrong."""
334
334
  scheduler, _removed = ttl
335
- monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
335
+ monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
336
336
 
337
337
  scheduler._run_ttl_check(MagicMock())
338
338
 
@@ -0,0 +1,134 @@
1
+ """Two processes scanning at once must not drop each other's cached images.
2
+
3
+ Review 2026-10-18: the registry's read-modify-write was guarded by a
4
+ ``threading.Lock``, which only serialises one process. A CLI scan and an MCP
5
+ server scan running side by side each read the old file, and the last
6
+ ``os.replace`` wrote the other's rows away. The atomic replace stops a reader
7
+ seeing half a file; it does nothing for a lost update.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import textwrap
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ from vmware_aiops.ops import datastore_browser as dsb
23
+
24
+ _WRITERS = 4
25
+
26
+ _CHILD = textwrap.dedent(
27
+ """
28
+ import sys, time
29
+ from pathlib import Path
30
+ from vmware_aiops.ops import datastore_browser as dsb
31
+
32
+ reg, target = Path(sys.argv[1]), sys.argv[2]
33
+ dsb.IMAGE_REGISTRY_FILE = reg
34
+ dsb.CONFIG_DIR = reg.parent
35
+ real_load = dsb._load_registry
36
+
37
+ def slow_load():
38
+ # Widen the read-modify-write window so the race is not left to luck.
39
+ data = real_load()
40
+ time.sleep(0.3)
41
+ return data
42
+
43
+ dsb._load_registry = slow_load
44
+ (reg.parent / f"ready-{target}").touch()
45
+ go = reg.parent / "go"
46
+ deadline = time.monotonic() + 60
47
+ while not go.exists():
48
+ if time.monotonic() > deadline:
49
+ sys.exit("parent never said go")
50
+ time.sleep(0.01)
51
+ img = {"name": f"{target}.iso", "ds_path": f"[datastore1] {target}.iso",
52
+ "size_mb": 1.0, "type": "File", "modified": ""}
53
+ dsb.scan_all_datastores = lambda si: {"datastore1": [img]}
54
+ dsb.update_registry(object(), target=target)
55
+ """
56
+ )
57
+
58
+
59
+ def _child_env() -> dict[str, str]:
60
+ env = dict(os.environ)
61
+ # The children must import exactly the code this test process imported.
62
+ env["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p)
63
+ return env
64
+
65
+
66
+ def _run_writers(reg: Path, script: str, targets: list[str]) -> None:
67
+ procs = [
68
+ subprocess.Popen([sys.executable, "-c", script, str(reg), t], env=_child_env())
69
+ for t in targets
70
+ ]
71
+ try:
72
+ deadline = time.monotonic() + 60
73
+ while not all((reg.parent / f"ready-{t}").exists() for t in targets):
74
+ assert time.monotonic() < deadline, "writers never became ready"
75
+ assert all(p.poll() is None for p in procs), "a writer exited early"
76
+ time.sleep(0.02)
77
+ (reg.parent / "go").touch()
78
+ for p in procs:
79
+ assert p.wait(timeout=60) == 0
80
+ finally:
81
+ for p in procs:
82
+ if p.poll() is None:
83
+ p.kill()
84
+
85
+
86
+ @pytest.mark.unit
87
+ def test_concurrent_scans_in_separate_processes_keep_every_row(tmp_path) -> None:
88
+ reg = tmp_path / "image_registry.json"
89
+ targets = [f"t{i}" for i in range(_WRITERS)]
90
+
91
+ _run_writers(reg, _CHILD, targets)
92
+
93
+ rows = json.loads(reg.read_text(encoding="utf-8"))["images"]
94
+ assert sorted(r["target"] for r in rows) == targets
95
+
96
+
97
+ @pytest.fixture
98
+ def registry(tmp_path, monkeypatch):
99
+ path = tmp_path / "image_registry.json"
100
+ monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
101
+ monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
102
+ img = {"name": "x.iso", "ds_path": "[ds1] x.iso", "size_mb": 1.0,
103
+ "type": "File", "modified": ""}
104
+ monkeypatch.setattr(dsb, "scan_all_datastores", lambda si: {"ds1": [img]})
105
+ return path
106
+
107
+
108
+ def _hold_lock(reg: Path):
109
+ fcntl = pytest.importorskip("fcntl")
110
+ fh = open(reg.with_name(reg.name + ".lock"), "a+b") # noqa: SIM115
111
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
112
+ return fh
113
+
114
+
115
+ @pytest.mark.unit
116
+ def test_a_held_lock_is_a_bounded_wait_that_names_the_lock_file(registry, monkeypatch) -> None:
117
+ monkeypatch.setattr(dsb, "REGISTRY_LOCK_TIMEOUT_S", 0.3, raising=False)
118
+ holder = _hold_lock(registry)
119
+ try:
120
+ started = time.monotonic()
121
+ # The scan is returned; the note says the cache was not updated and why.
122
+ out = dsb.update_registry(object(), target="vc")
123
+ assert "image_registry.json.lock" in out["cache_note"]
124
+ assert time.monotonic() - started < 5
125
+ finally:
126
+ holder.close()
127
+ assert not registry.exists(), "nothing may be written without the lock"
128
+
129
+
130
+ @pytest.mark.unit
131
+ def test_the_lock_is_released_after_a_write(registry) -> None:
132
+ dsb.update_registry(object(), target="vc")
133
+ holder = _hold_lock(registry) # would raise BlockingIOError if still held
134
+ holder.close()
@@ -0,0 +1,150 @@
1
+ """A scan replaces only the legacy rows it can attribute (review 2026-10-18).
2
+
3
+ Legacy rows are the ones written before rows carried ``target``. A scan of one
4
+ target deleted every legacy row in the file, including rows for datastores that
5
+ target does not have — the cache lost images no scan had looked at again.
6
+ VMware-Storage's ``_record_scan`` replaces a legacy row only when the scan
7
+ covered its datastore (and folder); this follows the same rule. A whole-target
8
+ scan covers each datastore it browsed successfully, whether or not it found
9
+ images there.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ import pytest
17
+
18
+ from vmware_aiops.ops import datastore_browser as dsb
19
+ from vmware_aiops.ops import inventory
20
+
21
+
22
+ def _legacy(ds: str, name: str) -> dict:
23
+ return {"datastore": ds, "name": name, "ds_path": f"[{ds}] {name}",
24
+ "size_mb": 1.0, "type": "File", "modified": ""}
25
+
26
+
27
+ def _img(ds: str, name: str) -> dict:
28
+ return {"name": name, "ds_path": f"[{ds}] {name}", "size_mb": 1.0,
29
+ "type": "File", "modified": ""}
30
+
31
+
32
+ @pytest.fixture
33
+ def registry(tmp_path, monkeypatch):
34
+ path = tmp_path / "image_registry.json"
35
+ monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
36
+ monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
37
+ return path
38
+
39
+
40
+ def _seed(path, rows: list[dict]) -> None:
41
+ path.write_text(json.dumps({"images": rows, "last_scan": None}), encoding="utf-8")
42
+
43
+
44
+ def _names() -> list[str]:
45
+ return sorted(i["name"] for i in dsb.list_images())
46
+
47
+
48
+ @pytest.mark.unit
49
+ def test_legacy_rows_on_datastores_the_scan_did_not_cover_survive(registry, monkeypatch) -> None:
50
+ _seed(registry, [_legacy("datastore1", "old.iso"), _legacy("nfs-b", "keep.iso")])
51
+ monkeypatch.setattr(dsb, "scan_all_datastores",
52
+ lambda si: {"datastore1": [_img("datastore1", "new.iso")]})
53
+
54
+ dsb.update_registry(object(), target="vc")
55
+
56
+ assert _names() == ["keep.iso", "new.iso"]
57
+
58
+
59
+ @pytest.mark.unit
60
+ def test_other_targets_rows_survive(registry, monkeypatch) -> None:
61
+ other = dict(_legacy("datastore1", "b.iso"), target="esx")
62
+ _seed(registry, [other])
63
+ monkeypatch.setattr(dsb, "scan_all_datastores",
64
+ lambda si: {"datastore1": [_img("datastore1", "a.iso")]})
65
+
66
+ dsb.update_registry(object(), target="vc")
67
+
68
+ assert _names() == ["a.iso", "b.iso"]
69
+
70
+
71
+ def _datastores(monkeypatch, names: list[str], found: dict[str, list[dict] | Exception]) -> None:
72
+ monkeypatch.setattr(inventory, "list_datastores",
73
+ lambda si: [{"name": n, "accessible": True} for n in names])
74
+
75
+ def fake_scan(si, ds_name, path=""):
76
+ res = found.get(ds_name, [])
77
+ if isinstance(res, Exception):
78
+ raise res
79
+ return res
80
+
81
+ monkeypatch.setattr(dsb, "scan_images", fake_scan)
82
+
83
+
84
+ @pytest.mark.unit
85
+ def test_a_datastore_scanned_empty_still_replaces_its_legacy_rows(registry, monkeypatch) -> None:
86
+ _seed(registry, [_legacy("empty-ds", "gone.iso")])
87
+ _datastores(monkeypatch, ["empty-ds"], {})
88
+
89
+ dsb.update_registry(object(), target="vc")
90
+
91
+ assert _names() == []
92
+
93
+
94
+ @pytest.mark.unit
95
+ def test_a_datastore_whose_scan_failed_keeps_its_legacy_rows(registry, monkeypatch) -> None:
96
+ _seed(registry, [_legacy("flaky-ds", "keep.iso")])
97
+ _datastores(monkeypatch, ["flaky-ds", "ok-ds"],
98
+ {"flaky-ds": RuntimeError("browse failed"), "ok-ds": [_img("ok-ds", "n.iso")]})
99
+
100
+ dsb.update_registry(object(), target="vc")
101
+
102
+ assert _names() == ["keep.iso", "n.iso"]
103
+
104
+
105
+ @pytest.mark.unit
106
+ def test_this_targets_rows_on_a_datastore_it_did_not_browse_survive(registry, monkeypatch) -> None:
107
+ """Review 2026-10-18: a failed or skipped browse is not "no images there"."""
108
+ _seed(registry, [dict(_legacy("ds2", "mine.iso"), target="vc"),
109
+ dict(_legacy("datastore1", "stale.iso"), target="vc")])
110
+ monkeypatch.setattr(dsb, "scan_all_datastores",
111
+ lambda si: {"datastore1": [_img("datastore1", "new.iso")], "ds2": None})
112
+
113
+ dsb.update_registry(object(), target="vc")
114
+
115
+ assert _names() == ["mine.iso", "new.iso"]
116
+
117
+
118
+ @pytest.mark.unit
119
+ def test_this_targets_rows_on_a_datastore_that_is_gone_are_dropped(registry, monkeypatch) -> None:
120
+ """Narrow review 2026-10-18: not listed at all is not "failed to browse"."""
121
+ _seed(registry, [dict(_legacy("removed-ds", "ghost.iso"), target="vc"),
122
+ dict(_legacy("flaky-ds", "kept.iso"), target="vc")])
123
+ _datastores(monkeypatch, ["ds1", "flaky-ds"],
124
+ {"ds1": [_img("ds1", "new.iso")], "flaky-ds": RuntimeError("browse failed")})
125
+
126
+ dsb.update_registry(object(), target="vc")
127
+
128
+ assert _names() == ["kept.iso", "new.iso"]
129
+
130
+
131
+ @pytest.mark.unit
132
+ def test_a_scan_whose_cache_cannot_be_locked_still_returns_its_images(registry, monkeypatch) -> None:
133
+ """Narrow review 2026-10-18: Storage returns the scan with a cache_note; so does AIops."""
134
+ import errno
135
+
136
+ from vmware_aiops import _filelock
137
+
138
+ monkeypatch.setattr(dsb, "scan_all_datastores",
139
+ lambda si: {"ds1": [_img("ds1", "new.iso")]})
140
+ if _filelock.fcntl is None:
141
+ pytest.skip("POSIX branch")
142
+
143
+ def no_locks(fd, op):
144
+ raise OSError(errno.ENOLCK, "No locks available")
145
+
146
+ monkeypatch.setattr(_filelock.fcntl, "flock", no_locks)
147
+ out = dsb.update_registry(object(), target="vc")
148
+
149
+ assert [i["name"] for i in out["images"]] == ["new.iso"]
150
+ assert "cache" in out["cache_note"].lower()
@@ -223,3 +223,59 @@ def test_mcp_not_authenticated_names_both_causes() -> None:
223
223
  assert "credentials" not in payload["hint"]
224
224
  text = _mcp_payload(vim.fault.NotAuthenticated(), shape="str")
225
225
  assert "vSphere role" in text and "verify connectivity and credentials" not in text
226
+
227
+
228
+ # 2026-10-18, lab vCenter 8.0.3, SSO account with no permission anywhere:
229
+ # vCenter refuses the *login itself* with NoPermission (System.View on the root
230
+ # folder). Before the fix that raw fault reached doctor and every tool.
231
+ def _login_refused(exc: Exception):
232
+ mgr = ConnectionManager(_config("vc1"))
233
+ with patch("pyVim.connect.SmartConnect", side_effect=exc):
234
+ try:
235
+ mgr.connect("vc1")
236
+ except Exception as e: # noqa: BLE001 — the test inspects whatever is raised
237
+ return e
238
+ raise AssertionError("connect did not raise")
239
+
240
+
241
+ def test_login_refused_with_no_permission_names_the_missing_role() -> None:
242
+ fault = vim.fault.NoPermission(
243
+ privilegeId="System.View", msg="Permission to perform this operation was denied."
244
+ )
245
+ err = _login_refused(fault)
246
+ assert isinstance(err, NoVSphereRoleError)
247
+ msg = str(err)
248
+ assert "no vSphere role" in msg and "Read-Only" in msg and "'vc1'" in msg
249
+ assert "dynamicType" not in msg and "operation failed" not in msg
250
+ assert len(msg) <= 300
251
+ assert err.__cause__ is fault
252
+
253
+
254
+ def test_wrong_password_is_not_reported_as_a_missing_role() -> None:
255
+ err = _login_refused(vim.fault.InvalidLogin())
256
+ assert not isinstance(err, NoVSphereRoleError)
257
+ assert isinstance(err, vim.fault.InvalidLogin)
258
+
259
+
260
+ @pytest.mark.parametrize(
261
+ ("privilege", "esxi"),
262
+ [("System.View", True), ("Sessions.ValidateSession", False), (None, False)],
263
+ )
264
+ def test_other_login_refusals_get_a_neutral_remedy(privilege, esxi) -> None:
265
+ # Review 2026-10-18 (M3): an ESXi host (lockdown mode, host permissions) or
266
+ # another privilege must not be sent to vCenter Global Permissions.
267
+ from dataclasses import replace
268
+
269
+ fault = vim.fault.NoPermission(privilegeId=privilege)
270
+ mgr = ConnectionManager(_config("vc1"))
271
+ if esxi:
272
+ esxi_targets = tuple(replace(t, type="esxi") for t in mgr._config.targets)
273
+ mgr._config = replace(mgr._config, targets=esxi_targets)
274
+ with patch("pyVim.connect.SmartConnect", side_effect=fault):
275
+ with pytest.raises(Exception) as info:
276
+ mgr.connect("vc1")
277
+ msg = str(info.value)
278
+ assert "Global Permissions" not in msg and "lockdown mode" in msg
279
+ if privilege:
280
+ assert f"({privilege})" in msg
281
+ assert info.value.__cause__ is fault and len(msg) <= 300