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 )