gridfleet-agent 0.2.0__tar.gz → 0.2.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 (114) hide show
  1. gridfleet_agent-0.2.2/CHANGELOG.md +50 -0
  2. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/PKG-INFO +1 -1
  3. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/install.py +13 -2
  4. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/plan.py +32 -6
  5. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/pyproject.toml +1 -1
  6. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_install.py +22 -2
  7. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_plan.py +37 -0
  8. gridfleet_agent-0.2.2/tests/test_install_script.py +141 -0
  9. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/uv.lock +1 -1
  10. gridfleet_agent-0.2.0/CHANGELOG.md +0 -28
  11. gridfleet_agent-0.2.0/tests/test_install_script.py +0 -58
  12. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/.gitignore +0 -0
  13. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/README.md +0 -0
  14. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/__init__.py +0 -0
  15. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/appium_process.py +0 -0
  16. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/capabilities.py +0 -0
  17. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/cli.py +0 -0
  18. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/config.py +0 -0
  19. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/driver_doctor.py +0 -0
  20. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/host_telemetry.py +0 -0
  21. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/__init__.py +0 -0
  22. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/status.py +0 -0
  23. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/uninstall.py +0 -0
  24. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/installer/update.py +0 -0
  25. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/main.py +0 -0
  26. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/observability.py +0 -0
  27. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/__init__.py +0 -0
  28. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/adapter_dispatch.py +0 -0
  29. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/adapter_loader.py +0 -0
  30. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/adapter_registry.py +0 -0
  31. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/adapter_types.py +0 -0
  32. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/adapter_utils.py +0 -0
  33. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/discovery.py +0 -0
  34. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/dispatch.py +0 -0
  35. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/host_identity.py +0 -0
  36. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/manifest.py +0 -0
  37. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/runtime.py +0 -0
  38. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/runtime_policy.py +0 -0
  39. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/runtime_registry.py +0 -0
  40. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/sidecar_supervisor.py +0 -0
  41. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/state.py +0 -0
  42. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/tarball_fetch.py +0 -0
  43. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/pack/version_catalog.py +0 -0
  44. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/plugin_manager.py +0 -0
  45. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/py.typed +0 -0
  46. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/registration.py +0 -0
  47. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/terminal_pty.py +0 -0
  48. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/terminal_ws.py +0 -0
  49. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/tool_paths.py +0 -0
  50. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/tool_utils.py +0 -0
  51. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/tools_manager.py +0 -0
  52. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/agent_app/version_guidance.py +0 -0
  53. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/__init__.py +0 -0
  54. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_cli_install.py +0 -0
  55. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_status.py +0 -0
  56. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_uninstall.py +0 -0
  57. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/installer/test_update.py +0 -0
  58. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/__init__.py +0 -0
  59. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/conftest.py +0 -0
  60. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_dispatch.py +0 -0
  61. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_loader.py +0 -0
  62. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_loader_cancel_path_leak.py +0 -0
  63. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_loader_concurrent_load.py +0 -0
  64. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_tarball_auth.py +0 -0
  65. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_utils.py +0 -0
  66. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_adapter_wiring.py +0 -0
  67. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_appium_process_integration.py +0 -0
  68. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_desired_manifest_features.py +0 -0
  69. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_feature_action_routes.py +0 -0
  70. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_host_identity.py +0 -0
  71. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_manifest_parser.py +0 -0
  72. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_pack_discovery_endpoint.py +0 -0
  73. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_pack_state.py +0 -0
  74. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_pack_state_client_auth.py +0 -0
  75. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_pack_state_sidecar_reconcile.py +0 -0
  76. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_runtime_github.py +0 -0
  77. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_runtime_isolated_failures.py +0 -0
  78. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_runtime_manager.py +0 -0
  79. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_runtime_plugins.py +0 -0
  80. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_runtime_policy.py +0 -0
  81. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_sidecar_supervisor.py +0 -0
  82. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_sidecar_supervisor_poll_stop_race.py +0 -0
  83. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_state_loop.py +0 -0
  84. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_state_loop_wired.py +0 -0
  85. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_tarball_fetch.py +0 -0
  86. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_tarball_fetch_concurrent_dedup.py +0 -0
  87. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/pack/test_version_catalog.py +0 -0
  88. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_agent_api.py +0 -0
  89. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_agent_api_more.py +0 -0
  90. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_appium_process.py +0 -0
  91. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_appium_process_port_alloc_race.py +0 -0
  92. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_appium_process_restart_stop_race.py +0 -0
  93. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_appium_process_stop_start_lock_race.py +0 -0
  94. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_appium_process_watch_stop_race.py +0 -0
  95. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_capabilities.py +0 -0
  96. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_capabilities_more.py +0 -0
  97. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_cli.py +0 -0
  98. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_config.py +0 -0
  99. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_config_runtime_root.py +0 -0
  100. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_driver_doctor.py +0 -0
  101. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_host_telemetry.py +0 -0
  102. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_no_driver_imports.py +0 -0
  103. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_observability.py +0 -0
  104. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_package_metadata.py +0 -0
  105. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_plugin_manager.py +0 -0
  106. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_plugin_manager_more.py +0 -0
  107. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_registration.py +0 -0
  108. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_terminal_pty.py +0 -0
  109. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_terminal_ws.py +0 -0
  110. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_tools_and_utilities_more.py +0 -0
  111. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_tools_manager.py +0 -0
  112. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_tools_manager_extra.py +0 -0
  113. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/tests/test_version_guidance.py +0 -0
  114. {gridfleet_agent-0.2.0 → gridfleet_agent-0.2.2}/uninstall.sh +0 -0
@@ -0,0 +1,50 @@
1
+ # Changelog — GridFleet Agent
2
+
3
+ All notable changes to the GridFleet host agent (`gridfleet-agent` on PyPI) are documented here.
4
+
5
+ ## [0.2.2](https://github.com/quidow/gridfleet/compare/gridfleet-agent-v0.2.1...gridfleet-agent-v0.2.2) (2026-05-03)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **agent:** prefer nvm node during install ([b0e672b](https://github.com/quidow/gridfleet/commit/b0e672b22e761593c657ae6d54b665f0112a61df))
11
+ * **agent:** support sh installer and auth hint ([81cc1fd](https://github.com/quidow/gridfleet/commit/81cc1fd0fb2d344baa135b31665f216d7d607c75))
12
+
13
+ ## [0.2.1](https://github.com/quidow/gridfleet/compare/gridfleet-agent-v0.2.0...gridfleet-agent-v0.2.1) (2026-05-02)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **agent:** close port-allocator and adapter-loader race windows ([#23](https://github.com/quidow/gridfleet/issues/23)) ([4bea799](https://github.com/quidow/gridfleet/commit/4bea799dd6f7931223ec2d2828de5c1e83bf8b8c))
19
+ * **agent:** dedup and isolate tarball_fetch targets ([#27](https://github.com/quidow/gridfleet/issues/27)) ([f83ac99](https://github.com/quidow/gridfleet/commit/f83ac991b8b7f9d1916b64fc465187f1995274c7))
20
+ * **agent:** hold _start_lock across AppiumProcessManager.stop() body ([#24](https://github.com/quidow/gridfleet/issues/24)) ([a42f1da](https://github.com/quidow/gridfleet/commit/a42f1da759e52add383e9eea0852a85d5633c4e8))
21
+ * **agent:** idempotent bootstrap installer with sudo and launchd handling ([#51](https://github.com/quidow/gridfleet/issues/51)) ([db0f059](https://github.com/quidow/gridfleet/commit/db0f059d5288979bbca314fbcf2e92e09e888be8))
22
+ * **agent:** reset to 0.2.0, drop --locked from ci ([c6ee2ea](https://github.com/quidow/gridfleet/commit/c6ee2eab4ba7d4b761136cdea1a929d6e22bca3f))
23
+ * **agent:** use importlib.metadata for version, fix publish lock files ([b96a112](https://github.com/quidow/gridfleet/commit/b96a112db50ef8e7c8d5bd1524104d7f27cb5afd))
24
+ * authenticate agent driver pack tarball fetches ([898859e](https://github.com/quidow/gridfleet/commit/898859eae0ced10a6109058ac6aeab4b6c851934))
25
+ * **ci:** update agent lock file, add auto-lockfile workflow, fix local commitlint hook ([920b71e](https://github.com/quidow/gridfleet/commit/920b71eeaa942b33c711a3dcb75115b37525947c))
26
+
27
+ ## 0.2.0
28
+
29
+ ### Features
30
+
31
+ - Rewrite bootstrap installer to use `uv tool install` instead of manual venv creation. Users no longer need Python 3.12+ pre-installed — `uv` handles it.
32
+ - Replace `validate_dedicated_venv` with `resolve_bin_path` — the agent no longer requires running from `/opt/gridfleet-agent/venv/bin/`. Supports `uv tool install` paths natively.
33
+ - Add `bin_path` to `InstallConfig` for configurable binary resolution in service unit templates (systemd/launchd).
34
+ - Replace `pip install --upgrade` with `uv tool upgrade gridfleet-agent` in the update flow.
35
+ - Add upgrade awareness: the agent caches version guidance from the manager's registration response and surfaces it on `/agent/health`, `HealthCheckResult.details`, and `gridfleet-agent status` CLI output.
36
+ - Use `importlib.metadata` for runtime version resolution — eliminates version sync issues between `pyproject.toml` and source.
37
+
38
+ ### Fixes
39
+
40
+ - Update CLI tests for removed venv validation guard.
41
+ - Close port-allocator and adapter-loader race windows.
42
+ - Deduplicate and isolate tarball fetch targets.
43
+ - Hold `_start_lock` across `AppiumProcessManager.stop()` body.
44
+ - Authenticate agent driver-pack tarball fetches.
45
+
46
+ ## 0.1.0 — Initial Public Preview
47
+
48
+ - Initial public preview of the GridFleet host agent.
49
+ - FastAPI agent that runs on each device host, spawning Appium processes and Selenium Grid relay nodes.
50
+ - Driver-pack runtime with manifest-driven adapter loading and isolated APPIUM_HOME.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-agent
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: GridFleet agent — runs on each host
5
5
  Project-URL: Homepage, https://github.com/quidow/gridfleet
6
6
  Project-URL: Repository, https://github.com/quidow/gridfleet
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import getpass
4
5
  import hashlib
5
6
  import os
@@ -213,7 +214,10 @@ def _start_service(
213
214
  return
214
215
  if os_name == "Darwin":
215
216
  resolved_uid = _resolve_uid(uid)
216
- run_command(["launchctl", "bootstrap", f"gui/{resolved_uid}", str(service_file)])
217
+ domain_target = f"gui/{resolved_uid}"
218
+ with contextlib.suppress(RuntimeError):
219
+ run_command(["launchctl", "bootout", f"{domain_target}/com.gridfleet.agent"])
220
+ run_command(["launchctl", "bootstrap", domain_target, str(service_file)])
217
221
  return
218
222
  raise RuntimeError(f"Unsupported OS: {os_name}")
219
223
 
@@ -287,7 +291,14 @@ def poll_manager_registration(
287
291
  )
288
292
  last_error = f"{resolved_hostname} was not listed"
289
293
  else:
290
- last_error = f"unexpected status {status_code}"
294
+ if status_code == 401:
295
+ last_error = (
296
+ "manager requires machine auth; rerun install with "
297
+ "--manager-auth-username and --manager-auth-password matching "
298
+ "GRIDFLEET_MACHINE_AUTH_USERNAME and GRIDFLEET_MACHINE_AUTH_PASSWORD"
299
+ )
300
+ else:
301
+ last_error = f"unexpected status {status_code}"
291
302
  except Exception as exc:
292
303
  last_error = str(exc)
293
304
  time.sleep(interval_sec)
@@ -125,13 +125,15 @@ def _find_java(env: Mapping[str, str], home: Path, os_name: str) -> tuple[str |
125
125
 
126
126
 
127
127
  def _find_node_bin_dir(env: Mapping[str, str], home: Path) -> str | None:
128
- node = shutil.which("node")
129
- if node:
130
- return str(Path(node).parent)
128
+ nvm_bin = env.get("NVM_BIN", "")
129
+ if nvm_bin:
130
+ executable = _first_existing_executable([f"{nvm_bin}/node"])
131
+ if executable:
132
+ return str(Path(executable).parent)
131
133
 
132
134
  nvm_root = home / ".nvm/versions/node"
133
135
  if nvm_root.is_dir():
134
- candidates = sorted(nvm_root.glob("v*/bin/node"), reverse=True)
136
+ candidates = sorted(nvm_root.glob("v*/bin/node"), key=_node_version_key, reverse=True)
135
137
  executable = _first_existing_executable([str(candidate) for candidate in candidates])
136
138
  if executable:
137
139
  return str(Path(executable).parent)
@@ -145,9 +147,23 @@ def _find_node_bin_dir(env: Mapping[str, str], home: Path) -> str | None:
145
147
  if executable:
146
148
  return str(Path(executable).parent)
147
149
 
150
+ node = shutil.which("node")
151
+ if node:
152
+ return str(Path(node).parent)
153
+
148
154
  return None
149
155
 
150
156
 
157
+ def _node_version_key(node_path: Path) -> tuple[int, ...]:
158
+ version = node_path.parent.parent.name.removeprefix("v")
159
+ parts: list[int] = []
160
+ for segment in version.split("."):
161
+ if not segment.isdecimal():
162
+ break
163
+ parts.append(int(segment))
164
+ return tuple(parts)
165
+
166
+
151
167
  def _find_android_home(env: Mapping[str, str], home: Path) -> str | None:
152
168
  for candidate in (
153
169
  env.get("ANDROID_HOME", ""),
@@ -162,14 +178,24 @@ def _find_android_home(env: Mapping[str, str], home: Path) -> str | None:
162
178
  return None
163
179
 
164
180
 
181
+ def _operator_home(env: Mapping[str, str]) -> Path:
182
+ sudo_user = env.get("SUDO_USER")
183
+ if sudo_user:
184
+ try:
185
+ return Path(f"~{sudo_user}").expanduser()
186
+ except RuntimeError:
187
+ pass
188
+ return Path.home()
189
+
190
+
165
191
  def discover_tools(
166
192
  *,
167
193
  env: Mapping[str, str] | None = None,
168
194
  home: Path | None = None,
169
195
  os_name: str | None = None,
170
196
  ) -> ToolDiscovery:
171
- resolved_env = env or os.environ
172
- resolved_home = home or Path.home()
197
+ resolved_env = os.environ if env is None else env
198
+ resolved_home = home or _operator_home(resolved_env)
173
199
  resolved_os = os_name or platform.system()
174
200
  warnings: list[str] = []
175
201
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridfleet-agent"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "GridFleet agent — runs on each host"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -279,7 +279,8 @@ def test_install_with_start_skips_manager_registration_when_health_fails(tmp_pat
279
279
  assert result.registration is None
280
280
 
281
281
 
282
- def test_install_with_start_runs_launchctl_bootstrap_on_macos(tmp_path: Path) -> None:
282
+ def test_install_with_start_runs_launchctl_bootstrap_on_macos(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
283
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
283
284
  config = _make_config(tmp_path)
284
285
  executable = Path(config.venv_bin_dir) / "gridfleet-agent"
285
286
  executable.parent.mkdir(parents=True)
@@ -299,7 +300,10 @@ def test_install_with_start_runs_launchctl_bootstrap_on_macos(tmp_path: Path) ->
299
300
 
300
301
  assert result.started is True
301
302
  assert result.health == HealthCheckResult(ok=False, message="health check timed out")
302
- assert commands == [["launchctl", "bootstrap", "gui/0", str(result.service_file)]]
303
+ assert commands == [
304
+ ["launchctl", "bootout", "gui/0/com.gridfleet.agent"],
305
+ ["launchctl", "bootstrap", "gui/0", str(result.service_file)],
306
+ ]
303
307
 
304
308
 
305
309
  def test_poll_manager_registration_returns_success_when_hostname_is_listed() -> None:
@@ -347,6 +351,22 @@ def test_poll_manager_registration_times_out_when_hostname_is_missing() -> None:
347
351
  assert "agent-host was not listed" in result.message
348
352
 
349
353
 
354
+ def test_poll_manager_registration_explains_auth_required_on_401() -> None:
355
+ config = InstallConfig(manager_url="https://manager.example.com")
356
+
357
+ def fake_get(_url: str, timeout: float = 2.0, auth: tuple[str, str] | None = None) -> object:
358
+ class Response:
359
+ status_code = 401
360
+
361
+ return Response()
362
+
363
+ result = poll_manager_registration(config, hostname="agent-host", timeout_sec=0.01, interval_sec=0.01, get=fake_get)
364
+
365
+ assert result.ok is False
366
+ assert "--manager-auth-username" in result.message
367
+ assert "--manager-auth-password" in result.message
368
+
369
+
350
370
  def test_install_with_start_raises_when_service_command_fails(tmp_path: Path) -> None:
351
371
  config = _make_config(tmp_path)
352
372
  executable = Path(config.venv_bin_dir) / "gridfleet-agent"
@@ -7,7 +7,9 @@ import pytest
7
7
  from agent_app.installer.plan import (
8
8
  InstallConfig,
9
9
  ToolDiscovery,
10
+ _find_node_bin_dir,
10
11
  build_service_path,
12
+ discover_tools,
11
13
  format_dry_run,
12
14
  load_installed_config,
13
15
  render_config_env,
@@ -179,6 +181,41 @@ def test_build_service_path_prepends_discovered_tool_dirs() -> None:
179
181
  assert build_service_path(discovery).startswith("/usr/lib/jvm/bin:/opt/node/bin:/opt/sdk/platform-tools:")
180
182
 
181
183
 
184
+ def test_find_node_bin_dir_prefers_home_nvm_over_system_node(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
185
+ nvm_node = tmp_path / ".nvm/versions/node/v24.12.0/bin/node"
186
+ nvm_node.parent.mkdir(parents=True)
187
+ nvm_node.write_text("")
188
+ nvm_node.chmod(0o755)
189
+ monkeypatch.setattr("agent_app.installer.plan.shutil.which", lambda _name: "/usr/bin/node")
190
+
191
+ assert _find_node_bin_dir({}, tmp_path) == str(nvm_node.parent)
192
+
193
+
194
+ def test_find_node_bin_dir_uses_highest_nvm_version(tmp_path: Path) -> None:
195
+ old_node = tmp_path / ".nvm/versions/node/v9.9.0/bin/node"
196
+ new_node = tmp_path / ".nvm/versions/node/v24.12.0/bin/node"
197
+ for node in (old_node, new_node):
198
+ node.parent.mkdir(parents=True)
199
+ node.write_text("")
200
+ node.chmod(0o755)
201
+
202
+ assert _find_node_bin_dir({}, tmp_path) == str(new_node.parent)
203
+
204
+
205
+ def test_discover_tools_uses_sudo_user_home_for_nvm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
206
+ sudo_home = tmp_path / "operator"
207
+ nvm_node = sudo_home / ".nvm/versions/node/v24.12.0/bin/node"
208
+ nvm_node.parent.mkdir(parents=True)
209
+ nvm_node.write_text("")
210
+ nvm_node.chmod(0o755)
211
+ monkeypatch.setattr("agent_app.installer.plan.Path.expanduser", lambda path: sudo_home)
212
+ monkeypatch.setattr("agent_app.installer.plan.shutil.which", lambda _name: "/usr/bin/node")
213
+
214
+ discovery = discover_tools(env={"SUDO_USER": "operator"}, os_name="Linux")
215
+
216
+ assert discovery.node_bin_dir == str(nvm_node.parent)
217
+
218
+
182
219
  def test_config_resolved_bin_path_defaults_to_venv() -> None:
183
220
  config = InstallConfig()
184
221
  assert config.resolved_bin_path == "/opt/gridfleet-agent/venv/bin/gridfleet-agent"
@@ -0,0 +1,141 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from stat import S_IXUSR
5
+
6
+ import pytest
7
+
8
+
9
+ def _write_executable(path: Path, content: str) -> None:
10
+ path.write_text(content)
11
+ path.chmod(0o755)
12
+
13
+
14
+ def test_bootstrap_wrapper_uses_uv_tool_install() -> None:
15
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
16
+ assert script.startswith("#!/bin/sh")
17
+ assert "uv tool install" in script
18
+ assert "gridfleet-agent" in script
19
+ assert "--python 3.12" in script
20
+
21
+
22
+ def test_bootstrap_wrapper_installs_uv_if_missing() -> None:
23
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
24
+ assert "astral.sh/uv/install.sh" in script
25
+ assert "command -v uv" in script
26
+
27
+
28
+ def test_bootstrap_wrapper_calls_gridfleet_agent_install() -> None:
29
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
30
+ assert "gridfleet-agent install" in script
31
+
32
+
33
+ def test_bootstrap_wrapper_is_executable() -> None:
34
+ script_path = Path(__file__).resolve().parents[2] / "scripts/install-agent.sh"
35
+ assert script_path.stat().st_mode & S_IXUSR
36
+
37
+
38
+ def test_bootstrap_wrapper_runs_under_sh(tmp_path: Path) -> None:
39
+ bin_dir = tmp_path / "bin"
40
+ bin_dir.mkdir()
41
+ log = tmp_path / "commands.log"
42
+ env = os.environ | {"PATH": f"{bin_dir}:{os.environ['PATH']}", "COMMAND_LOG": str(log)}
43
+ script_path = Path(__file__).resolve().parents[2] / "scripts/install-agent.sh"
44
+
45
+ _write_executable(
46
+ bin_dir / "uv",
47
+ '#!/usr/bin/env bash\nprintf \'uv %s\\n\' "$*" >> "$COMMAND_LOG"\n',
48
+ )
49
+ _write_executable(
50
+ bin_dir / "gridfleet-agent",
51
+ '#!/usr/bin/env bash\nprintf \'gridfleet-agent %s\\n\' "$*" >> "$COMMAND_LOG"\n',
52
+ )
53
+ _write_executable(bin_dir / "uname", "#!/usr/bin/env bash\necho Linux\n")
54
+ _write_executable(bin_dir / "id", '#!/usr/bin/env bash\n[ "$1" = "-u" ] && echo 0\n')
55
+
56
+ result = subprocess.run(
57
+ ["sh", str(script_path), "--dry-run", "--manager-url", "https://manager.example.com"],
58
+ check=False,
59
+ capture_output=True,
60
+ text=True,
61
+ env=env,
62
+ )
63
+
64
+ assert result.returncode == 0, result.stderr
65
+ commands = log.read_text()
66
+ assert "uv tool install --upgrade --python 3.12 gridfleet-agent" in commands
67
+ assert "gridfleet-agent install --dry-run --manager-url https://manager.example.com" in commands
68
+
69
+
70
+ def test_bootstrap_wrapper_supports_version_pinning() -> None:
71
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
72
+ assert "VERSION" in script
73
+ assert "gridfleet-agent==" in script
74
+
75
+
76
+ def test_bootstrap_wrapper_defaults_to_start_mode() -> None:
77
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
78
+ assert "--start" in script
79
+ assert "--dry-run" in script
80
+ assert "--no-start" in script
81
+
82
+
83
+ @pytest.mark.parametrize("mode_args", [("--dry-run",), ("--no-start",), ("--start", "--dry-run")])
84
+ def test_bootstrap_wrapper_only_stops_service_for_start_mode(tmp_path: Path, mode_args: tuple[str, ...]) -> None:
85
+ bin_dir = tmp_path / "bin"
86
+ bin_dir.mkdir()
87
+ log = tmp_path / "commands.log"
88
+ env = os.environ | {"PATH": f"{bin_dir}:{os.environ['PATH']}", "COMMAND_LOG": str(log)}
89
+ script_path = Path(__file__).resolve().parents[2] / "scripts/install-agent.sh"
90
+
91
+ _write_executable(
92
+ bin_dir / "uv",
93
+ '#!/usr/bin/env bash\nprintf \'uv %s\\n\' "$*" >> "$COMMAND_LOG"\n',
94
+ )
95
+ _write_executable(
96
+ bin_dir / "gridfleet-agent",
97
+ '#!/usr/bin/env bash\nprintf \'gridfleet-agent %s\\n\' "$*" >> "$COMMAND_LOG"\n',
98
+ )
99
+ _write_executable(
100
+ bin_dir / "systemctl",
101
+ '#!/usr/bin/env bash\nprintf \'systemctl %s\\n\' "$*" >> "$COMMAND_LOG"\n',
102
+ )
103
+ _write_executable(bin_dir / "uname", "#!/usr/bin/env bash\necho Linux\n")
104
+ _write_executable(bin_dir / "id", '#!/usr/bin/env bash\n[ "$1" = "-u" ] && echo 1000\n')
105
+ _write_executable(
106
+ bin_dir / "sudo",
107
+ '#!/usr/bin/env bash\nprintf \'sudo %s\\n\' "$*" >> "$COMMAND_LOG"\n"$@"\n',
108
+ )
109
+
110
+ result = subprocess.run(
111
+ [str(script_path), *mode_args, "--manager-url", "https://manager.example.com"],
112
+ check=False,
113
+ capture_output=True,
114
+ text=True,
115
+ env=env,
116
+ )
117
+
118
+ assert result.returncode == 0, result.stderr
119
+ commands = log.read_text()
120
+ assert "systemctl stop gridfleet-agent" not in commands
121
+ assert f"gridfleet-agent install {' '.join(mode_args)} --manager-url https://manager.example.com" in commands
122
+
123
+
124
+ def test_bootstrap_wrapper_does_not_use_python_venv() -> None:
125
+ script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
126
+ assert "python3 -m venv" not in script
127
+ assert "pip install" not in script
128
+
129
+
130
+ def test_operator_docs_point_to_bootstrap_wrapper_not_legacy_install_script() -> None:
131
+ root = Path(__file__).resolve().parents[2]
132
+ docs = {
133
+ "README.md": (root / "README.md").read_text(),
134
+ "docs/guides/deployment.md": (root / "docs/guides/deployment.md").read_text(),
135
+ "docs/reference/environment.md": (root / "docs/reference/environment.md").read_text(),
136
+ }
137
+ for text in docs.values():
138
+ assert "scripts/install-agent.sh" in text
139
+ assert "bash agent/install.sh" not in text
140
+ assert "./agent/install.sh" not in text
141
+ assert "./agent/update.sh" not in text
@@ -169,7 +169,7 @@ wheels = [
169
169
 
170
170
  [[package]]
171
171
  name = "gridfleet-agent"
172
- version = "0.2.0"
172
+ version = "0.2.2"
173
173
  source = { editable = "." }
174
174
  dependencies = [
175
175
  { name = "fastapi" },
@@ -1,28 +0,0 @@
1
- # Changelog — GridFleet Agent
2
-
3
- All notable changes to the GridFleet host agent (`gridfleet-agent` on PyPI) are documented here.
4
-
5
- ## 0.2.0
6
-
7
- ### Features
8
-
9
- - Rewrite bootstrap installer to use `uv tool install` instead of manual venv creation. Users no longer need Python 3.12+ pre-installed — `uv` handles it.
10
- - Replace `validate_dedicated_venv` with `resolve_bin_path` — the agent no longer requires running from `/opt/gridfleet-agent/venv/bin/`. Supports `uv tool install` paths natively.
11
- - Add `bin_path` to `InstallConfig` for configurable binary resolution in service unit templates (systemd/launchd).
12
- - Replace `pip install --upgrade` with `uv tool upgrade gridfleet-agent` in the update flow.
13
- - Add upgrade awareness: the agent caches version guidance from the manager's registration response and surfaces it on `/agent/health`, `HealthCheckResult.details`, and `gridfleet-agent status` CLI output.
14
- - Use `importlib.metadata` for runtime version resolution — eliminates version sync issues between `pyproject.toml` and source.
15
-
16
- ### Fixes
17
-
18
- - Update CLI tests for removed venv validation guard.
19
- - Close port-allocator and adapter-loader race windows.
20
- - Deduplicate and isolate tarball fetch targets.
21
- - Hold `_start_lock` across `AppiumProcessManager.stop()` body.
22
- - Authenticate agent driver-pack tarball fetches.
23
-
24
- ## 0.1.0 — Initial Public Preview
25
-
26
- - Initial public preview of the GridFleet host agent.
27
- - FastAPI agent that runs on each device host, spawning Appium processes and Selenium Grid relay nodes.
28
- - Driver-pack runtime with manifest-driven adapter loading and isolated APPIUM_HOME.
@@ -1,58 +0,0 @@
1
- from pathlib import Path
2
- from stat import S_IXUSR
3
-
4
-
5
- def test_bootstrap_wrapper_uses_uv_tool_install() -> None:
6
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
7
- assert script.startswith("#!/usr/bin/env bash")
8
- assert "uv tool install" in script
9
- assert "gridfleet-agent" in script
10
- assert "--python 3.12" in script
11
-
12
-
13
- def test_bootstrap_wrapper_installs_uv_if_missing() -> None:
14
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
15
- assert "astral.sh/uv/install.sh" in script
16
- assert "command -v uv" in script
17
-
18
-
19
- def test_bootstrap_wrapper_calls_gridfleet_agent_install() -> None:
20
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
21
- assert "gridfleet-agent install" in script
22
-
23
-
24
- def test_bootstrap_wrapper_is_executable() -> None:
25
- script_path = Path(__file__).resolve().parents[2] / "scripts/install-agent.sh"
26
- assert script_path.stat().st_mode & S_IXUSR
27
-
28
-
29
- def test_bootstrap_wrapper_supports_version_pinning() -> None:
30
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
31
- assert "VERSION" in script
32
- assert "gridfleet-agent==" in script
33
-
34
-
35
- def test_bootstrap_wrapper_defaults_to_start_mode() -> None:
36
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
37
- assert "--start" in script
38
- assert "--dry-run|--no-start|--start" in script
39
-
40
-
41
- def test_bootstrap_wrapper_does_not_use_python_venv() -> None:
42
- script = (Path(__file__).resolve().parents[2] / "scripts/install-agent.sh").read_text()
43
- assert "python3 -m venv" not in script
44
- assert "pip install" not in script
45
-
46
-
47
- def test_operator_docs_point_to_bootstrap_wrapper_not_legacy_install_script() -> None:
48
- root = Path(__file__).resolve().parents[2]
49
- docs = {
50
- "README.md": (root / "README.md").read_text(),
51
- "docs/guides/deployment.md": (root / "docs/guides/deployment.md").read_text(),
52
- "docs/reference/environment.md": (root / "docs/reference/environment.md").read_text(),
53
- }
54
- for text in docs.values():
55
- assert "scripts/install-agent.sh" in text
56
- assert "bash agent/install.sh" not in text
57
- assert "./agent/install.sh" not in text
58
- assert "./agent/update.sh" not in text