vmware-storage 2.3.1__tar.gz → 2.4.1__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 (117) hide show
  1. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/.claude-plugin/plugin.json +1 -1
  2. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/.mcp.json +1 -1
  3. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/PKG-INFO +2 -1
  4. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/README-CN.md +1 -0
  5. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/README.md +1 -0
  6. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/RELEASE_NOTES.md +21 -0
  7. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/openclaw.plugin.json +1 -1
  8. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/package.json +1 -1
  9. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/pyproject.toml +1 -1
  10. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/server.json +2 -2
  11. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/SKILL.md +2 -2
  12. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/references/setup-guide.md +4 -3
  13. vmware_storage-2.4.1/tests/eval/regression/test_browse_ds_path_join.py +79 -0
  14. vmware_storage-2.4.1/tests/eval/regression/test_browse_sees_every_file.py +70 -0
  15. vmware_storage-2.4.1/tests/eval/regression/test_error_hints_fit_the_surface.py +60 -0
  16. vmware_storage-2.4.1/tests/eval/regression/test_scan_populates_image_cache.py +184 -0
  17. vmware_storage-2.4.1/tests/test_policy_roles.py +55 -0
  18. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/uv.lock +7 -7
  19. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/__init__.py +1 -1
  20. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/cli.py +4 -2
  21. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/config.py +8 -3
  22. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/mcp_server/server.py +37 -9
  23. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/datastore_browser.py +126 -60
  24. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/policy_environment.py +4 -0
  25. vmware_storage-2.4.1/vmware_storage/policy_roles.py +52 -0
  26. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/.clawhubignore +0 -0
  27. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/.env.example +0 -0
  28. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/.gitignore +0 -0
  29. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/Dockerfile +0 -0
  30. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/LICENSE +0 -0
  31. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/SECURITY.md +0 -0
  32. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/config.example.yaml +0 -0
  33. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/docker-compose.yml +0 -0
  34. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/README.md +0 -0
  35. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/claude-code.json +0 -0
  36. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/continue.yaml +0 -0
  37. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/cursor.json +0 -0
  38. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/goose.json +0 -0
  39. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/localcowork.json +0 -0
  40. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/mcp-agent.yaml +0 -0
  41. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/examples/mcp-configs/vscode-copilot.json +0 -0
  42. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/patterns/README.md +0 -0
  43. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/patterns/iscsi-target-stale-rescan.yaml +0 -0
  44. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/evals/evals.json +0 -0
  45. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/references/agent-guardrails.md +0 -0
  46. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/references/capabilities.md +0 -0
  47. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/skills/vmware-storage/references/cli-reference.md +0 -0
  48. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/smithery.yaml +0 -0
  49. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/__init__.py +0 -0
  50. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/conftest.py +0 -0
  51. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/__init__.py +0 -0
  52. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/__init__.py +0 -0
  53. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/_family.py +0 -0
  54. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/_scores.json +0 -0
  55. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/_scoring.py +0 -0
  56. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/_skill.py +0 -0
  57. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/conftest.py +0 -0
  58. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/test_entity_reachability.py +0 -0
  59. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/test_error_actionability.py +0 -0
  60. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/test_tool_description_quality.py +0 -0
  61. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  62. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/__init__.py +0 -0
  63. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
  64. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_audit_isolation.py +0 -0
  65. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_cached_loader_agrees_with_the_resolver.py +0 -0
  66. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_capability_grader.py +0 -0
  67. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
  68. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_credential_env_override.py +0 -0
  69. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_datastore_list_renders.py +0 -0
  70. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
  71. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_doctor_uses_the_config_the_tools_use.py +0 -0
  72. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
  73. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_fc_multipath.py +0 -0
  74. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_instructions_name_targets.py +0 -0
  75. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_inventory_scale_n1.py +0 -0
  76. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_iscsi_unreachable_host.py +0 -0
  77. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_login_without_a_role.py +0 -0
  78. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_release_blockers.py +0 -0
  79. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_remedy_survives_a_long_home.py +0 -0
  80. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_result_envelope.py +0 -0
  81. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
  82. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_session_probe_eviction.py +0 -0
  83. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_storage_hardening_fixes.py +0 -0
  84. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_storage_ops_behavior.py +0 -0
  85. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_tool_names_2_0.py +0 -0
  86. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
  87. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_efficiency.py +0 -0
  88. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_health_is_actually_queried.py +0 -0
  89. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/regression/test_vsan_reports_what_it_could_not_read.py +0 -0
  90. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/spec/__init__.py +0 -0
  91. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/spec/vsan_efficiency_endpoints.py +0 -0
  92. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/eval/spec/vsan_health_endpoints.py +0 -0
  93. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_access_info.py +0 -0
  94. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_init_and_onboarding.py +0 -0
  95. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_iscsi_gate.py +0 -0
  96. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_no_destructive_ops.py +0 -0
  97. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_no_destructive_vm_code.py +0 -0
  98. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_safe_error_passthrough.py +0 -0
  99. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_session_pool.py +0 -0
  100. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/tests/test_stop_signal_runs_atexit.py +0 -0
  101. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/access_requirements.py +0 -0
  102. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/connection.py +0 -0
  103. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/doctor.py +0 -0
  104. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/init_wizard.py +0 -0
  105. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/mcp_server/__init__.py +0 -0
  106. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/mcp_server/__main__.py +0 -0
  107. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/notify/__init__.py +0 -0
  108. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/notify/audit.py +0 -0
  109. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/__init__.py +0 -0
  110. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/inventory.py +0 -0
  111. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/iscsi_config.py +0 -0
  112. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/iscsi_gate.py +0 -0
  113. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/multipath.py +0 -0
  114. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/storage_paths.py +0 -0
  115. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/vsan.py +0 -0
  116. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/vsan_efficiency.py +0 -0
  117. {vmware_storage-2.3.1 → vmware_storage-2.4.1}/vmware_storage/ops/vsan_sdk.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "vmware-storage",
4
4
  "displayName": "VMware Storage",
5
- "version": "2.3.1",
5
+ "version": "2.4.1",
6
6
  "description": "VMware vSphere storage with 15 MCP tools: datastores, iSCSI, vSAN, FC multipath.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "vmware-storage==2.3.1",
8
+ "vmware-storage==2.4.1",
9
9
  "vmware-storage-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-storage
3
- Version: 2.3.1
3
+ Version: 2.4.1
4
4
  Summary: VMware vSphere storage management: datastores, iSCSI, vSAN. Domain-focused MCP skill.
5
5
  Project-URL: Homepage, https://github.com/vmware-skills/VMware-Storage
6
6
  Project-URL: Repository, https://github.com/vmware-skills/VMware-Storage
@@ -329,6 +329,7 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
329
329
  - The audit log records each caller as `token:<name>`. Calls run side by side, also on the same target: the server keeps up to `VMWARE_MCP_POOL_SIZE` (default 2) sessions per target, each one a login on that target, and a call waits at most 300 s for a free session (vmware-policy ≥ 1.22.0).
330
330
  - **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
331
331
  nothing on the platform. Run one endpoint per privilege level. `vmware-storage access-info` shows which account that is and what it may do.
332
+ - **Several people or roles on one endpoint:** run `vmware-audit roles enable` once, then give each token a role (`vmware-audit token create NAME --role reader`, or a role in `roles.yaml` naming tools and targets; vmware-policy ≥ 1.24.0). A role limits what the token may call on this endpoint; it cannot grant more than the account has. See vmware-policy's README, "Roles for network callers".
332
333
 
333
334
  **Offer only read tools, plus the writes you choose:**
334
335
 
@@ -341,6 +341,7 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
341
341
  - 审计日志把每个调用者记为 `token:<名字>`。调用并行执行,同一 target 上也一样:server 为每个 target 最多保持 `VMWARE_MCP_POOL_SIZE`(默认 2)个会话,每个会话都是在该 target 上的一次登录;一个调用最多等 300 秒拿到空闲会话(vmware-policy ≥ 1.22.0)。
342
342
  - **能连到这个端点的人,都以该 server 配置的账号操作。** token 只用来识别调用者,不在平台上授予任何权限。
343
343
  每个权限级别单独起一个端点。`vmware-storage access-info` 可查看是哪个账号、它能做什么。
344
+ - **一个端点给多人或多种角色用:** 先运行一次 `vmware-audit roles enable`,再给每个 token 指定角色(`vmware-audit token create 名字 --role reader`,或在 `roles.yaml` 里定义可用的工具和 target;需 vmware-policy ≥ 1.24.0)。角色只能限定这个 token 在本端点上能调用什么,不能超出账号本身的权限。详见 vmware-policy README 的「网络调用者的角色」一节。
344
345
 
345
346
  **只提供读工具,加上你选定的写工具:**
346
347
 
@@ -308,6 +308,7 @@ claude mcp add --transport http vmware-storage https://mcp.example.com:8443/mcp
308
308
  - The audit log records each caller as `token:<name>`. Calls run side by side, also on the same target: the server keeps up to `VMWARE_MCP_POOL_SIZE` (default 2) sessions per target, each one a login on that target, and a call waits at most 300 s for a free session (vmware-policy ≥ 1.22.0).
309
309
  - **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
310
310
  nothing on the platform. Run one endpoint per privilege level. `vmware-storage access-info` shows which account that is and what it may do.
311
+ - **Several people or roles on one endpoint:** run `vmware-audit roles enable` once, then give each token a role (`vmware-audit token create NAME --role reader`, or a role in `roles.yaml` naming tools and targets; vmware-policy ≥ 1.24.0). A role limits what the token may call on this endpoint; it cannot grant more than the account has. See vmware-policy's README, "Roles for network callers".
311
312
 
312
313
  **Offer only read tools, plus the writes you choose:**
313
314
 
@@ -1,3 +1,24 @@
1
+ ## v2.4.1 — fixes from real-hardware testing (2026-10-17)
2
+
3
+ * **`list_cached_images` returns what was scanned.** Nothing ever wrote the cache it reads, so it was always empty.
4
+ `scan_datastore_images` now replaces that target's entries for the scanned datastore folder; entries carry
5
+ `target`, so `datastore1` on two hosts stays apart. The cache file is written atomically under a lock; a cache that
6
+ cannot be read or written is reported in `cache_note` and does not fail the scan.
7
+ * **`browse_datastore` / `scan_datastore_images` return well-formed `ds_path`s** for both ESXi and vCenter folder
8
+ paths (an ESXi browse produced `[datastore1] backupX.iso`). A leading `/` is still refused; the error now names the
9
+ corrected path, and vmware-aiops follows the same rule.
10
+ * Error hints: `doctor` is suggested for connection and credential problems, not for an invalid path or an unknown
11
+ target; the unknown-target error names `target=` with the CLI flag in brackets.
12
+
13
+ ## v2.4.0 — roles for network callers (HLD §18)
14
+
15
+ * Declares the role metadata vmware-policy ≥ 1.24.0 needs to apply **roles** to this server over HTTPS
16
+ (HLD §18): how a missing target resolves. With an older vmware-policy nothing changes. Once an operator runs
17
+ `vmware-audit roles enable`, a token's role decides which tools and targets it may use here; a refused call is
18
+ audited as `denied`. See vmware-policy's README, "Roles for network callers".
19
+ * A target-limited role is checked against the same config the connection uses: the copy the server's connection manager loaded on its first connection. Edit `config.yaml` and restart for both to see the change.
20
+ * README and setup guide: one endpoint for several people or roles.
21
+
1
22
  ## v2.3.1 — a login with no vSphere role is named as such (AIops#47)
2
23
 
3
24
  * An account that logs in but has no vSphere role (vCenter shows no session for it) is now refused at connect time
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "vmware-storage==2.3.1",
14
+ "vmware-storage==2.4.1",
15
15
  "vmware-storage-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/vmware-storage",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "description": "VMware vSphere storage with 15 MCP tools: datastores, iSCSI, vSAN, FC multipath.",
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-storage"
7
- version = "2.3.1"
7
+ version = "2.4.1"
8
8
  description = "VMware vSphere storage management: datastores, iSCSI, vSAN. Domain-focused MCP skill."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-Storage",
8
8
  "source": "github"
9
9
  },
10
- "version": "2.3.1",
10
+ "version": "2.4.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-storage",
15
- "version": "2.3.1",
15
+ "version": "2.4.1",
16
16
  "runtimeHint": "uvx",
17
17
  "packageArguments": [
18
18
  {
@@ -40,7 +40,7 @@ VMware vSphere storage management — 15 MCP tools for datastores, iSCSI, vSAN,
40
40
  ## Quick Install
41
41
 
42
42
  ```bash
43
- uv tool install vmware-storage==2.3.1
43
+ uv tool install vmware-storage==2.4.1
44
44
  vmware-storage init # guided setup: writes config + .env (chmod 600, password grep-safe), then verifies
45
45
  vmware-storage doctor
46
46
  ```
@@ -271,7 +271,7 @@ Corporate TLS proxies inject certificates that uv's bundled CA store doesn't tru
271
271
  ## Setup
272
272
 
273
273
  ```bash
274
- uv tool install vmware-storage==2.3.1
274
+ uv tool install vmware-storage==2.4.1
275
275
  mkdir -p ~/.vmware-storage
276
276
  cp config.example.yaml ~/.vmware-storage/config.yaml
277
277
  # Edit config.yaml with your vCenter/ESXi targets
@@ -13,19 +13,19 @@ Complete setup and security guide for `vmware-storage`.
13
13
  ### Via uv (recommended)
14
14
 
15
15
  ```bash
16
- uv tool install vmware-storage==2.3.1
16
+ uv tool install vmware-storage==2.4.1
17
17
  ```
18
18
 
19
19
  ### Via pip
20
20
 
21
21
  ```bash
22
- pip install vmware-storage==2.3.1
22
+ pip install vmware-storage==2.4.1
23
23
  ```
24
24
 
25
25
  ### From source
26
26
 
27
27
  ```bash
28
- git clone --branch v2.3.1 https://github.com/vmware-skills/VMware-Storage.git
28
+ git clone --branch v2.4.1 https://github.com/vmware-skills/VMware-Storage.git
29
29
  cd VMware-Storage
30
30
  pip install -e .
31
31
  ```
@@ -250,6 +250,7 @@ vmware-storage mcp --transport https --host 0.0.0.0 --port 8443 \
250
250
  - **Everyone who can reach the endpoint acts with this server's account.** Tokens identify callers; they grant
251
251
  nothing on the platform. Run one endpoint per privilege level (for example a read-only account behind
252
252
  `--expose read`, and a separate endpoint for operators).
253
+ - **Several people or roles on one endpoint:** run `vmware-audit roles enable` once, then give each token a role (`vmware-audit token create NAME --role reader`, or a role in `roles.yaml` naming tools and targets; vmware-policy ≥ 1.24.0). A role limits what the token may call on this endpoint; it cannot grant more than the account has. See vmware-policy's README, "Roles for network callers".
253
254
 
254
255
  ## Security Details
255
256
 
@@ -0,0 +1,79 @@
1
+ """browse_datastore must return a ds_path that deploy/attach tools can use.
2
+
3
+ Regression source: real ESXi 8 host (home-esxi, 2026-10-17). The browse task
4
+ reports each folder's ``folderPath`` with no trailing separator —
5
+ ``"[datastore1]"`` for the root, ``"[datastore1] backup"`` for a folder —
6
+ and the old code glued the file name straight on, producing
7
+ ``"[datastore1] backupVMware-VCSA.iso"`` and ``"[datastore1]Hermers - TT"``.
8
+ Agents pass that string into deploy_vm_from_ova / attach_iso_to_vm, so a
9
+ malformed path breaks the very flow the browse exists for. The earlier tests
10
+ mocked ``"[ds1] images/"`` (trailing slash), the one shape that hid the bug.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from types import SimpleNamespace
15
+
16
+ import pytest
17
+ from pyVmomi import vim
18
+
19
+ from vmware_storage.ops import datastore_browser as dsb
20
+
21
+
22
+ @pytest.mark.unit
23
+ @pytest.mark.parametrize(
24
+ ("folder", "name", "expected"),
25
+ [
26
+ # Root, as ESXi reports it: no space after the bracket.
27
+ ("[datastore1]", "Hermers - TT", "[datastore1] Hermers - TT"),
28
+ ("[datastore1]", "x.iso", "[datastore1] x.iso"),
29
+ # Root, as vCenter reports it: trailing space.
30
+ ("[datastore1] ", "x.iso", "[datastore1] x.iso"),
31
+ # Folder without a trailing slash (ESXi).
32
+ ("[datastore1] backup", "x.iso", "[datastore1] backup/x.iso"),
33
+ (
34
+ "[datastore1] Hermers - TT",
35
+ "Hermers - TT.vmdk",
36
+ "[datastore1] Hermers - TT/Hermers - TT.vmdk",
37
+ ),
38
+ ("[datastore1] a/b", "x.iso", "[datastore1] a/b/x.iso"),
39
+ # Folder with a trailing slash (vCenter) — must not double it.
40
+ ("[datastore1] backup/", "x.iso", "[datastore1] backup/x.iso"),
41
+ ],
42
+ )
43
+ def test_join_ds_path(folder, name, expected):
44
+ assert dsb._join_ds_path(folder, name) == expected
45
+
46
+
47
+ @pytest.mark.unit
48
+ def test_browse_root_returns_well_formed_paths_for_every_folder(monkeypatch):
49
+ """Root browse is recursive: subfolder rows come back too, each well formed."""
50
+ def _file(path):
51
+ return SimpleNamespace(path=path, fileSize=1024 * 1024, modification="2026-10-17")
52
+
53
+ results = [
54
+ SimpleNamespace(folderPath="[datastore1]", file=[_file("backup")]),
55
+ SimpleNamespace(folderPath="[datastore1] backup", file=[_file("x.iso")]),
56
+ ]
57
+ seen = {}
58
+
59
+ def fake_search(datastorePath, searchSpec): # noqa: N803 — pyVmomi API names
60
+ seen["path"] = datastorePath
61
+ return SimpleNamespace(
62
+ info=SimpleNamespace(state=vim.TaskInfo.State.success, result=results, error=None)
63
+ )
64
+
65
+ monkeypatch.setattr(
66
+ dsb,
67
+ "find_datastore_by_name",
68
+ lambda si, name: SimpleNamespace(
69
+ browser=SimpleNamespace(SearchDatastoreSubFolders_Task=fake_search)
70
+ ),
71
+ )
72
+
73
+ rows = dsb.browse_datastore(object(), "datastore1")["items"]
74
+
75
+ assert seen["path"] == "[datastore1]"
76
+ assert {r["name"]: r["ds_path"] for r in rows} == {
77
+ "backup": "[datastore1] backup",
78
+ "x.iso": "[datastore1] backup/x.iso",
79
+ }
@@ -0,0 +1,70 @@
1
+ """browse_datastore must see every file, and treat a leading '/' one way.
2
+
3
+ Real hardware, 2026-10-17: vmware-aiops' search spec listed only IsoImageQuery,
4
+ VmDiskQuery and FolderQuery under a comment saying "include all file types".
5
+ A DatastoreBrowser query is a filter, not a hint -- with those three, .ova /
6
+ .ovf / .vmx / .log files are never returned. On the lab vCenter it found 0 of
7
+ the 2 OVAs on 'datastore1 (1)' that vmware-storage (which also passes the
8
+ generic Query) found, so scan_all_datastore_images could not do the one job its
9
+ description gives it: finding an OVA for deploy_vm_from_ova.
10
+
11
+ The twin also drifted on a leading slash: vmware-aiops passed '/vcsa8' through,
12
+ vmware-storage refused it as "absolute" (its 1.5.35 hardening). Both now refuse
13
+ it, and the error names the corrected path.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from types import SimpleNamespace
18
+
19
+ import pytest
20
+ from pyVmomi import vim
21
+
22
+ from vmware_storage.ops import datastore_browser as dsb
23
+
24
+
25
+ def _browse(monkeypatch, path=""):
26
+ seen: dict = {}
27
+
28
+ def fake_search(datastorePath, searchSpec): # noqa: N803 -- pyVmomi API names
29
+ seen["path"] = datastorePath
30
+ seen["spec"] = searchSpec
31
+ return SimpleNamespace(
32
+ info=SimpleNamespace(state=vim.TaskInfo.State.success, result=[], error=None)
33
+ )
34
+
35
+ monkeypatch.setattr(
36
+ dsb,
37
+ "find_datastore_by_name",
38
+ lambda si, name: SimpleNamespace(
39
+ browser=SimpleNamespace(SearchDatastoreSubFolders_Task=fake_search)
40
+ ),
41
+ )
42
+ dsb.browse_datastore(object(), "datastore1", path=path, pattern="*.ova")
43
+ return seen
44
+
45
+
46
+ @pytest.mark.unit
47
+ def test_search_spec_includes_the_generic_file_query(monkeypatch) -> None:
48
+ spec = _browse(monkeypatch)["spec"]
49
+ # Exact type: every specific query subclasses Query, so isinstance would pass
50
+ # for the three that hid the OVAs.
51
+ assert any(type(q) is vim.host.DatastoreBrowser.Query for q in spec.query)
52
+
53
+
54
+ @pytest.mark.unit
55
+ def test_relative_path_is_joined_under_the_datastore(monkeypatch) -> None:
56
+ assert _browse(monkeypatch, "vcsa8")["path"] == "[datastore1] vcsa8"
57
+
58
+
59
+ @pytest.mark.unit
60
+ @pytest.mark.parametrize("path", ["/vcsa8", "//vcsa8"])
61
+ def test_leading_slash_is_refused_with_the_fix(monkeypatch, path) -> None:
62
+ with pytest.raises(ValueError, match="path='vcsa8'"):
63
+ _browse(monkeypatch, path)
64
+
65
+
66
+ @pytest.mark.unit
67
+ @pytest.mark.parametrize("path", ["../other", "a/../../b", "x\x00y", "\\\\server\\share", "/"])
68
+ def test_traversal_and_odd_paths_are_refused(monkeypatch, path) -> None:
69
+ with pytest.raises(ValueError, match="datastore-relative"):
70
+ _browse(monkeypatch, path)
@@ -0,0 +1,60 @@
1
+ """Error hints must fit the error, and the surface that shows them.
2
+
3
+ Real hardware, 2026-10-17: every MCP error carried "Run 'vmware-storage doctor'
4
+ to verify connectivity." -- also for an invalid path or a datastore name that
5
+ does not resolve, where the message already says what to change. And the
6
+ unknown-target error said "Pass --target", a CLI flag; the MCP name is the
7
+ ``target`` parameter.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ from vmware_storage.config import AppConfig
15
+ from vmware_storage.mcp_server import server
16
+
17
+
18
+ def _unknown_target():
19
+ from vmware_storage.config import UnknownTargetError
20
+
21
+ return UnknownTargetError("Target 'x' not found.")
22
+
23
+
24
+ @pytest.mark.unit
25
+ @pytest.mark.parametrize("exc", [ValueError("bad"), _unknown_target()])
26
+ def test_an_authored_request_error_does_not_send_the_agent_to_doctor(exc) -> None:
27
+ assert "doctor" not in server._hint_for(exc)
28
+
29
+
30
+ @pytest.mark.unit
31
+ @pytest.mark.parametrize("exc", [ConnectionError("x"), TimeoutError("x"), RuntimeError("x")])
32
+ def test_connectivity_and_unplanned_errors_keep_the_doctor_hint(exc) -> None:
33
+ assert server._hint_for(exc) == server._DOCTOR_HINT
34
+
35
+
36
+ @pytest.mark.unit
37
+ def test_unknown_target_names_the_mcp_parameter() -> None:
38
+ with pytest.raises(KeyError) as info:
39
+ AppConfig(targets=()).get_target("nope")
40
+ assert "target=" in str(info.value)
41
+
42
+
43
+
44
+ @pytest.mark.unit
45
+ @pytest.mark.parametrize("exc", [
46
+ __import__("json").JSONDecodeError("Expecting value", "<html>", 0),
47
+ KeyError("summary.capacity"),
48
+ UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
49
+ ])
50
+ def test_parse_errors_and_bare_key_errors_keep_the_doctor_hint(exc) -> None:
51
+ """Independent review 2026-10-17: a reverse proxy answering HTML raises
52
+ JSONDecodeError (a ValueError), and a programming slip raises a bare KeyError;
53
+ neither message says what to change, so they must not be told it does."""
54
+ assert server._hint_for(exc) == server._DOCTOR_HINT
55
+
56
+
57
+ @pytest.mark.unit
58
+ def test_an_unknown_target_is_still_a_key_error() -> None:
59
+ """Callers catch KeyError from get_target; the subclass must not break them."""
60
+ assert issubclass(type(_unknown_target()), KeyError)
@@ -0,0 +1,184 @@
1
+ """scan_datastore_images must fill the cache that list_cached_images reads.
2
+
3
+ Real hardware, 2026-10-17: list_cached_images was always empty. Its description
4
+ says the registry is "populated by prior datastore scans", but the only writer,
5
+ ``update_registry()``, had no caller anywhere -- no MCP tool, no CLI command --
6
+ so the tool could never return anything, and SKILL.md told agents to prefer it
7
+ over re-scanning.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from vmware_storage.ops import datastore_browser as dsb
17
+
18
+
19
+ def _img(ds: str, rel: str) -> dict:
20
+ return {
21
+ "name": rel.rsplit("/", 1)[-1],
22
+ "size_mb": 1.0,
23
+ "type": "File",
24
+ "modified": "",
25
+ "ds_path": f"[{ds}] {rel}",
26
+ }
27
+
28
+
29
+ @pytest.fixture
30
+ def registry(tmp_path, monkeypatch):
31
+ path = tmp_path / "image_registry.json"
32
+ monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
33
+ monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
34
+ return path
35
+
36
+
37
+ def _scan_returns(monkeypatch, rows_by_pattern: dict[str, list[dict]]) -> None:
38
+ def fake_browse(si, ds_name, path="", pattern="*"):
39
+ rows = rows_by_pattern.get(pattern, [])
40
+ return {"items": rows, "total": len(rows)}
41
+
42
+ monkeypatch.setattr(dsb, "browse_datastore", fake_browse)
43
+
44
+
45
+ @pytest.mark.unit
46
+ def test_scan_then_list_cached_returns_what_was_scanned(registry, monkeypatch) -> None:
47
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "ec-wrkr.ova")]})
48
+
49
+ dsb.scan_images(object(), "ds1")
50
+ cached = dsb.list_images(image_type="ova")
51
+
52
+ assert [i["ds_path"] for i in cached["items"]] == ["[ds1] ec-wrkr.ova"]
53
+ assert cached["items"][0]["datastore"] == "ds1"
54
+ assert json.loads(registry.read_text(encoding="utf-8"))["last_scan"]
55
+
56
+
57
+ @pytest.mark.unit
58
+ def test_rescan_replaces_that_datastore_and_keeps_the_others(registry, monkeypatch) -> None:
59
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "old.ova")]})
60
+ dsb.scan_images(object(), "ds1")
61
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds2", "x.iso")]})
62
+ dsb.scan_images(object(), "ds2")
63
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "new.ova")]})
64
+ dsb.scan_images(object(), "ds1")
65
+
66
+ paths = sorted(i["ds_path"] for i in dsb.list_images()["items"])
67
+ assert paths == ["[ds1] new.ova", "[ds2] x.iso"]
68
+
69
+
70
+ @pytest.mark.unit
71
+ def test_subfolder_scan_only_replaces_that_subfolder(registry, monkeypatch) -> None:
72
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "a/one.ova"), _img("ds1", "b/two.ova")]})
73
+ dsb.scan_images(object(), "ds1")
74
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "a/three.ova")]})
75
+ dsb.scan_images(object(), "ds1", path="a")
76
+
77
+ paths = sorted(i["ds_path"] for i in dsb.list_images()["items"])
78
+ assert paths == ["[ds1] a/three.ova", "[ds1] b/two.ova"]
79
+
80
+
81
+ @pytest.mark.unit
82
+ def test_an_unwritable_cache_does_not_fail_the_scan(registry, monkeypatch) -> None:
83
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "x.ova")]})
84
+
85
+ def boom(_registry):
86
+ raise OSError("read-only file system")
87
+
88
+ monkeypatch.setattr(dsb, "_save_registry", boom)
89
+ result = dsb.scan_images(object(), "ds1")
90
+
91
+ assert result["total"] == 1
92
+ assert "cache_note" in result
93
+
94
+
95
+ # ── independent review, 2026-10-17 ───────────────────────────────────────────
96
+
97
+
98
+ @pytest.mark.unit
99
+ def test_a_corrupt_cache_does_not_fail_the_scan(registry, monkeypatch) -> None:
100
+ registry.write_text("{not json", encoding="utf-8")
101
+ _scan_returns(monkeypatch, {"*.ova": [_img("ds1", "x.ova")]})
102
+
103
+ result = dsb.scan_images(object(), "ds1")
104
+
105
+ assert result["total"] == 1 and "cache_note" in result
106
+
107
+
108
+ @pytest.mark.unit
109
+ def test_a_corrupt_cache_tells_the_reader_how_to_recover(registry) -> None:
110
+ registry.write_text("[1, 2]", encoding="utf-8")
111
+ with pytest.raises(dsb.DatastoreBrowseError, match="scan_datastore_images"):
112
+ dsb.list_images()
113
+
114
+
115
+ @pytest.mark.unit
116
+ def test_the_same_datastore_name_on_two_targets_is_kept_apart(registry, monkeypatch) -> None:
117
+ """'datastore1' exists on every ESXi host; one target's scan must not erase another's."""
118
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a.iso")]})
119
+ dsb.scan_images(object(), "ds1", target="vc-a")
120
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "b.iso")]})
121
+ dsb.scan_images(object(), "ds1", target="esx-b")
122
+
123
+ items = dsb.list_images()["items"]
124
+ assert sorted((i["target"], i["ds_path"]) for i in items) == [
125
+ ("esx-b", "[ds1] b.iso"), ("vc-a", "[ds1] a.iso")]
126
+
127
+
128
+ @pytest.mark.unit
129
+ def test_a_dot_slash_path_replaces_that_folder(registry, monkeypatch) -> None:
130
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a/old.iso")]})
131
+ dsb.scan_images(object(), "ds1")
132
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "a/new.iso")]})
133
+ dsb.scan_images(object(), "ds1", path="./a")
134
+
135
+ assert [i["ds_path"] for i in dsb.list_images()["items"]] == ["[ds1] a/new.iso"]
136
+
137
+
138
+ @pytest.mark.unit
139
+ def test_concurrent_scans_lose_no_entries(registry, monkeypatch) -> None:
140
+ """With the HTTPS session pool, scans run side by side."""
141
+ import threading
142
+
143
+ def fake_browse(si, ds_name, path="", pattern="*"):
144
+ rows = [_img(ds_name, "x.iso")] if pattern == "*.iso" else []
145
+ return {"items": rows, "total": len(rows)}
146
+
147
+ monkeypatch.setattr(dsb, "browse_datastore", fake_browse)
148
+ threads = [threading.Thread(target=dsb.scan_images, args=(object(), f"ds{i}"))
149
+ for i in range(20)]
150
+ for t in threads:
151
+ t.start()
152
+ for t in threads:
153
+ t.join()
154
+
155
+ assert dsb.list_images()["total"] == 20
156
+
157
+
158
+ @pytest.mark.unit
159
+ def test_the_registry_is_replaced_atomically(registry, monkeypatch) -> None:
160
+ """A write that dies half-way must leave the previous file, not a truncated one."""
161
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "keep.iso")]})
162
+ dsb.scan_images(object(), "ds1")
163
+ before = registry.read_text(encoding="utf-8")
164
+
165
+ def boom(*_a, **_k):
166
+ raise OSError("disk full")
167
+
168
+ monkeypatch.setattr(dsb.json, "dump", boom)
169
+ monkeypatch.setattr(dsb.json, "dumps", boom)
170
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds2", "y.iso")]})
171
+ dsb.scan_images(object(), "ds2")
172
+
173
+ assert registry.read_text(encoding="utf-8") == before
174
+
175
+
176
+ @pytest.mark.unit
177
+ def test_a_row_from_before_targets_is_replaced_not_duplicated(registry, monkeypatch) -> None:
178
+ registry.write_text('{"images": [{"datastore": "ds1", "name": "x.iso", '
179
+ '"ds_path": "[ds1] x.iso"}], "last_scan": null}', encoding="utf-8")
180
+ _scan_returns(monkeypatch, {"*.iso": [_img("ds1", "x.iso")]})
181
+ dsb.scan_images(object(), "ds1", target="vc")
182
+ dsb.scan_images(object(), "ds1", target="vc")
183
+
184
+ assert dsb.list_images(datastore="ds1")["total"] == 1
@@ -0,0 +1,55 @@
1
+ """HLD §18: the role metadata this skill declares matches its real tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import pytest
8
+
9
+ from vmware_storage import policy_roles
10
+
11
+
12
+ def _registry() -> set[str]:
13
+ from vmware_storage.mcp_server.server import mcp
14
+
15
+ return {t.name for t in asyncio.run(mcp.list_tools())}
16
+
17
+
18
+ @pytest.mark.unit
19
+ def test_every_declared_name_is_a_real_tool():
20
+ named = set(policy_roles.ESCAPES) | set(getattr(policy_roles, "AKO_TOOLS", ()))
21
+ assert named - _registry() == set(), sorted(named - _registry())
22
+
23
+
24
+ @pytest.mark.unit
25
+ def test_metadata_is_declared_when_policy_has_roles():
26
+ roles = pytest.importorskip("vmware_policy.roles")
27
+ import vmware_storage.mcp_server.server # noqa: F401 (registers the metadata on import)
28
+
29
+ meta = roles.metadata("storage")
30
+ assert meta is not None and meta.version == 1
31
+ assert meta.escapes == policy_roles.ESCAPES
32
+ assert (meta.resolver is not None) is True
33
+
34
+
35
+ @pytest.mark.unit
36
+ def test_the_resolver_uses_the_config_the_connection_manager_holds(monkeypatch):
37
+ """Review H1/H-1: once the server's connection manager exists, a config edit must
38
+ not move the role check off the config it connects with; before, read the file."""
39
+ import importlib
40
+ import types
41
+
42
+ from vmware_storage import policy_roles as pr
43
+
44
+ server = importlib.import_module("vmware_storage.mcp_server.server")
45
+ assert hasattr(server, "_conn_mgr"), "policy_roles looks up a global the server does not have"
46
+ edited, loads = object(), []
47
+ monkeypatch.setattr(pr, "load_config", lambda: loads.append(1) or edited)
48
+
49
+ monkeypatch.setattr(server, "_conn_mgr", None)
50
+ assert pr._connection_config() is edited and pr._connection_config() is edited
51
+ assert len(loads) == 2 # no manager yet: the file as it is now, every time
52
+
53
+ held = object()
54
+ monkeypatch.setattr(server, "_conn_mgr", types.SimpleNamespace(_config=held))
55
+ assert pr._connection_config() is held # the manager's copy, not the edited file