/ tests / tools / test_terminal_requirements.py
test_terminal_requirements.py
  1  import importlib
  2  import logging
  3  
  4  import pytest
  5  
  6  terminal_tool_module = importlib.import_module("tools.terminal_tool")
  7  
  8  
  9  def _clear_terminal_env(monkeypatch):
 10      """Remove terminal env vars that could affect requirements checks."""
 11      keys = [
 12          "TERMINAL_ENV",
 13          "TERMINAL_CONTAINER_CPU",
 14          "TERMINAL_CONTAINER_DISK",
 15          "TERMINAL_CONTAINER_MEMORY",
 16          "TERMINAL_DOCKER_FORWARD_ENV",
 17          "TERMINAL_DOCKER_VOLUMES",
 18          "TERMINAL_LIFETIME_SECONDS",
 19          "TERMINAL_MODAL_MODE",
 20          "TERMINAL_SSH_HOST",
 21          "TERMINAL_SSH_PORT",
 22          "TERMINAL_SSH_USER",
 23          "TERMINAL_TIMEOUT",
 24          "TERMINAL_VERCEL_RUNTIME",
 25          "MODAL_TOKEN_ID",
 26          "MODAL_TOKEN_SECRET",
 27          "VERCEL_OIDC_TOKEN",
 28          "VERCEL_TOKEN",
 29          "VERCEL_PROJECT_ID",
 30          "VERCEL_TEAM_ID",
 31          "HOME",
 32          "USERPROFILE",
 33      ]
 34      for key in keys:
 35          monkeypatch.delenv(key, raising=False)
 36      # Default: no Nous subscription — patch both the terminal_tool local
 37      # binding and tool_backend_helpers (used by resolve_modal_backend_state).
 38      monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: False)
 39      import tools.tool_backend_helpers as _tbh
 40      monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: False)
 41  
 42  
 43  def test_local_terminal_requirements(monkeypatch, caplog):
 44      """Local backend uses Hermes' own LocalEnvironment wrapper."""
 45      _clear_terminal_env(monkeypatch)
 46      monkeypatch.setenv("TERMINAL_ENV", "local")
 47  
 48      with caplog.at_level(logging.ERROR):
 49          ok = terminal_tool_module.check_terminal_requirements()
 50  
 51      assert ok is True
 52      assert "Terminal requirements check failed" not in caplog.text
 53  
 54  
 55  def test_unknown_terminal_env_logs_error_and_returns_false(monkeypatch, caplog):
 56      _clear_terminal_env(monkeypatch)
 57      monkeypatch.setenv("TERMINAL_ENV", "unknown-backend")
 58  
 59      with caplog.at_level(logging.ERROR):
 60          ok = terminal_tool_module.check_terminal_requirements()
 61  
 62      assert ok is False
 63      assert any(
 64          "Unknown TERMINAL_ENV 'unknown-backend'" in record.getMessage()
 65          for record in caplog.records
 66      )
 67  
 68  
 69  def test_ssh_backend_without_host_or_user_logs_and_returns_false(monkeypatch, caplog):
 70      _clear_terminal_env(monkeypatch)
 71      monkeypatch.setenv("TERMINAL_ENV", "ssh")
 72  
 73      with caplog.at_level(logging.ERROR):
 74          ok = terminal_tool_module.check_terminal_requirements()
 75  
 76      assert ok is False
 77      assert any(
 78          "SSH backend selected but TERMINAL_SSH_HOST and TERMINAL_SSH_USER" in record.getMessage()
 79          for record in caplog.records
 80      )
 81  
 82  
 83  def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, caplog, tmp_path):
 84      _clear_terminal_env(monkeypatch)
 85      monkeypatch.setenv("TERMINAL_ENV", "modal")
 86      monkeypatch.setenv("HOME", str(tmp_path))
 87      monkeypatch.setenv("USERPROFILE", str(tmp_path))
 88      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
 89      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
 90  
 91      with caplog.at_level(logging.ERROR):
 92          ok = terminal_tool_module.check_terminal_requirements()
 93  
 94      assert ok is False
 95      assert any(
 96          "Modal backend selected but no direct Modal credentials/config was found" in record.getMessage()
 97          for record in caplog.records
 98      )
 99  
100  
101  def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
102      _clear_terminal_env(monkeypatch)
103      monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True)
104      import tools.tool_backend_helpers as _tbh
105      monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True)
106      monkeypatch.setenv("TERMINAL_ENV", "modal")
107      monkeypatch.setenv("HOME", str(tmp_path))
108      monkeypatch.setenv("USERPROFILE", str(tmp_path))
109      monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
110      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
111      monkeypatch.setattr(
112          terminal_tool_module.importlib.util,
113          "find_spec",
114          lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")),
115      )
116  
117      assert terminal_tool_module.check_terminal_requirements() is True
118  
119  
120  def test_modal_backend_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path):
121      _clear_terminal_env(monkeypatch)
122      monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True)
123      import tools.tool_backend_helpers as _tbh
124      monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True)
125      monkeypatch.setenv("TERMINAL_ENV", "modal")
126      monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
127      monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
128      monkeypatch.setenv("HOME", str(tmp_path))
129      monkeypatch.setenv("USERPROFILE", str(tmp_path))
130      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
131      monkeypatch.setattr(
132          terminal_tool_module.importlib.util,
133          "find_spec",
134          lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")),
135      )
136  
137      assert terminal_tool_module.check_terminal_requirements() is True
138  
139  
140  def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, caplog, tmp_path):
141      _clear_terminal_env(monkeypatch)
142      monkeypatch.setenv("TERMINAL_ENV", "modal")
143      monkeypatch.setenv("TERMINAL_MODAL_MODE", "direct")
144      monkeypatch.setenv("HOME", str(tmp_path))
145      monkeypatch.setenv("USERPROFILE", str(tmp_path))
146      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
147  
148      with caplog.at_level(logging.ERROR):
149          ok = terminal_tool_module.check_terminal_requirements()
150  
151      assert ok is False
152      assert any(
153          "TERMINAL_MODAL_MODE=direct" in record.getMessage()
154          for record in caplog.records
155      )
156  
157  
158  def test_modal_backend_managed_mode_does_not_fall_back_to_direct(monkeypatch, caplog, tmp_path):
159      _clear_terminal_env(monkeypatch)
160      monkeypatch.setenv("TERMINAL_ENV", "modal")
161      monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
162      monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
163      monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
164      monkeypatch.setenv("HOME", str(tmp_path))
165      monkeypatch.setenv("USERPROFILE", str(tmp_path))
166      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
167  
168      with caplog.at_level(logging.ERROR):
169          ok = terminal_tool_module.check_terminal_requirements()
170  
171      assert ok is False
172      assert any(
173          "paid Nous subscription is required" in record.getMessage()
174          for record in caplog.records
175      )
176  
177  
178  def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkeypatch, caplog, tmp_path):
179      _clear_terminal_env(monkeypatch)
180      monkeypatch.setenv("TERMINAL_ENV", "modal")
181      monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
182      monkeypatch.setenv("HOME", str(tmp_path))
183      monkeypatch.setenv("USERPROFILE", str(tmp_path))
184      monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
185  
186      with caplog.at_level(logging.ERROR):
187          ok = terminal_tool_module.check_terminal_requirements()
188  
189      assert ok is False
190      assert any(
191          "paid Nous subscription is required" in record.getMessage()
192          for record in caplog.records
193      )
194  
195  
196  def test_vercel_backend_without_sdk_logs_specific_error(monkeypatch, caplog):
197      _clear_terminal_env(monkeypatch)
198      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
199      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: None)
200  
201      with caplog.at_level(logging.ERROR):
202          ok = terminal_tool_module.check_terminal_requirements()
203  
204      assert ok is False
205      assert any(
206          "vercel is required for the Vercel Sandbox terminal backend" in record.getMessage()
207          for record in caplog.records
208      )
209  
210  
211  def test_vercel_backend_without_auth_logs_specific_error(monkeypatch, caplog):
212      _clear_terminal_env(monkeypatch)
213      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
214      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
215  
216      with caplog.at_level(logging.ERROR):
217          ok = terminal_tool_module.check_terminal_requirements()
218  
219      assert ok is False
220      assert any(
221          "no supported auth configuration was found" in record.getMessage()
222          for record in caplog.records
223      )
224  
225  
226  def test_vercel_backend_accepts_oidc_auth(monkeypatch):
227      _clear_terminal_env(monkeypatch)
228      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
229      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
230      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
231  
232      assert terminal_tool_module.check_terminal_requirements() is True
233  
234  
235  def test_vercel_backend_accepts_token_tuple_auth(monkeypatch):
236      _clear_terminal_env(monkeypatch)
237      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
238      monkeypatch.setenv("VERCEL_TOKEN", "token")
239      monkeypatch.setenv("VERCEL_PROJECT_ID", "project")
240      monkeypatch.setenv("VERCEL_TEAM_ID", "team")
241      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
242  
243      assert terminal_tool_module.check_terminal_requirements() is True
244  
245  
246  @pytest.mark.parametrize("runtime", ["node24", "node22", "python3.13"])
247  def test_vercel_backend_accepts_supported_runtimes(monkeypatch, runtime):
248      _clear_terminal_env(monkeypatch)
249      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
250      monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", runtime)
251      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
252      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
253  
254      assert terminal_tool_module.check_terminal_requirements() is True
255  
256  
257  def test_vercel_backend_accepts_blank_runtime(monkeypatch):
258      _clear_terminal_env(monkeypatch)
259      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
260      monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "   ")
261      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
262      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
263  
264      assert terminal_tool_module.check_terminal_requirements() is True
265  
266  
267  def test_vercel_backend_rejects_unsupported_runtime(monkeypatch, caplog):
268      _clear_terminal_env(monkeypatch)
269      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
270      monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "node20")
271      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
272      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
273  
274      with caplog.at_level(logging.ERROR):
275          ok = terminal_tool_module.check_terminal_requirements()
276  
277      assert ok is False
278      assert any(
279          "Vercel Sandbox runtime 'node20' is not supported" in record.getMessage()
280          and "node24, node22, python3.13" in record.getMessage()
281          for record in caplog.records
282      )
283  
284  
285  def test_vercel_backend_rejects_nondefault_disk(monkeypatch, caplog):
286      _clear_terminal_env(monkeypatch)
287      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
288      monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "8192")
289      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
290      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
291  
292      with caplog.at_level(logging.ERROR):
293          ok = terminal_tool_module.check_terminal_requirements()
294  
295      assert ok is False
296      assert any(
297          "does not support custom TERMINAL_CONTAINER_DISK=8192" in record.getMessage()
298          for record in caplog.records
299      )
300  
301  
302  def test_vercel_backend_rejects_malformed_disk_without_raising(monkeypatch, caplog):
303      _clear_terminal_env(monkeypatch)
304      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
305      monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "large")
306      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
307      monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
308  
309      with caplog.at_level(logging.ERROR):
310          ok = terminal_tool_module.check_terminal_requirements()
311  
312      assert ok is False
313      assert any(
314          "Invalid value for TERMINAL_CONTAINER_DISK" in record.getMessage()
315          for record in caplog.records
316      )