diff --git a/hermes_cli/codex_runtime_switch.py b/hermes_cli/codex_runtime_switch.py index b3adda12b5455..c9e6fbfb4059a 100644 --- a/hermes_cli/codex_runtime_switch.py +++ b/hermes_cli/codex_runtime_switch.py @@ -144,8 +144,12 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]: codex_version=ver if ok else None, ) - # No change requested - if new_value == current: + reapply_codex_app_server = new_value == current == "codex_app_server" + + # No change requested. Re-applying codex_app_server is not a pure no-op: + # the persisted config value can already be correct while ~/.codex still + # lacks Hermes' managed MCP/plugin migration block. + if new_value == current and not reapply_codex_app_server: return CodexRuntimeStatus( success=True, new_value=current, @@ -172,8 +176,9 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]: codex_version=None, ) - set_runtime(config, new_value) - if persist_callback is not None: + if not reapply_codex_app_server: + set_runtime(config, new_value) + if persist_callback is not None and not reapply_codex_app_server: try: persist_callback(config) except Exception as exc: @@ -185,9 +190,15 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]: message=f"updated config in memory but persist failed: {exc}", ) - msg_lines = [ - f"openai_runtime: {current} → {new_value}", - ] + if reapply_codex_app_server: + msg_lines = [ + "openai_runtime already set to codex_app_server - " + "re-applying migration", + ] + else: + msg_lines = [ + f"openai_runtime: {current} → {new_value}", + ] if new_value == "codex_app_server": ok, ver = _check_binary_cached() if ok: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a865bcaf8be2e..e0955d6c9fcf9 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2108,24 +2108,30 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]: if project_root is None: project_root = PROJECT_ROOT + def _is_dir(path: Path) -> bool: + try: + return path.is_dir() + except OSError: + return False + candidates = [] venv_bin = project_root / "venv" / "bin" - if venv_bin.is_dir(): + if _is_dir(venv_bin): candidates.append(str(venv_bin)) elif sys.prefix != sys.base_prefix: candidates.append(str(Path(sys.prefix) / "bin")) node_bin = project_root / "node_modules" / ".bin" - if node_bin.is_dir(): + if _is_dir(node_bin): candidates.append(str(node_bin)) hermes_home = get_hermes_home() hermes_node = hermes_home / "node" / "bin" - if hermes_node.is_dir(): + if _is_dir(hermes_node): candidates.append(str(hermes_node)) hermes_nm = hermes_home / "node_modules" / ".bin" - if hermes_nm.is_dir(): + if _is_dir(hermes_nm): candidates.append(str(hermes_nm)) return candidates diff --git a/tests/hermes_cli/test_codex_runtime_switch.py b/tests/hermes_cli/test_codex_runtime_switch.py index 7bf1a59e1e728..9e4ce1128a112 100644 --- a/tests/hermes_cli/test_codex_runtime_switch.py +++ b/tests/hermes_cli/test_codex_runtime_switch.py @@ -96,6 +96,42 @@ def test_no_change_when_already_set(self): assert r.success assert r.message == "openai_runtime already set to auto" + def test_reapply_codex_app_server_runs_migration_without_persist(self): + cfg = { + "model": {"openai_runtime": "codex_app_server"}, + "mcp_servers": { + "filesystem": {"command": "npx", "args": ["-y", "fs-server"]}, + }, + } + persisted = False + + def persist(c): + nonlocal persisted + persisted = True + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig: + mig.return_value.migrated = ["filesystem", "hermes-tools"] + mig.return_value.migrated_plugins = [] + mig.return_value.plugin_query_error = None + mig.return_value.wrote_permissions_default = ":workspace" + mig.return_value.errors = [] + mig.return_value.target_path = "/fake/.codex/config.toml" + r = crs.apply(cfg, "codex_app_server", persist_callback=persist) + + assert r.success + assert r.new_value == "codex_app_server" + assert r.old_value == "codex_app_server" + assert r.requires_new_session is True + assert persisted is False + mig.assert_called_once_with(cfg) + assert cfg["model"]["openai_runtime"] == "codex_app_server" + assert "already set to codex_app_server" in r.message + assert "re-applying migration" in r.message + assert "Migrated 1 MCP server" in r.message + assert "via MCP" in r.message + def test_enable_blocked_when_codex_missing(self): cfg = {} with patch.object(crs, "check_codex_binary_ok", diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c65c22004a9aa..cf619ea974333 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -254,8 +254,12 @@ def test_original_messages_not_mutated(self, monkeypatch): assert messages[0]["role"] == "system" def test_developer_role_via_nous_portal(self, monkeypatch): - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") - agent.model = "gpt-5" + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "hi"}, @@ -346,14 +350,24 @@ def test_includes_tools(self, monkeypatch): class TestBuildApiKwargsNousPortal: def test_includes_nous_product_tags(self, monkeypatch): from agent.portal_tags import nous_portal_tags - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [{"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) extra = kwargs.get("extra_body", {}) assert extra.get("tags") == nous_portal_tags() def test_uses_chat_completions_format(self, monkeypatch): - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [{"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) assert "messages" in kwargs diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index a28c777a8f1a5..365b910d4cc0c 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -58,6 +58,33 @@ def test_import_after_config_env_patch_uses_restored_dotenv_loader(self): finally: importlib.reload(tt) + def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self): + """xAI HTTP auth must not cache a temporarily patched env helper.""" + import importlib + import hermes_cli.config as config_mod + from tools import xai_http + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(config_mod, "get_env_value", lambda name, default=None: "") + xai_http = importlib.reload(xai_http) + + try: + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=RuntimeError("no oauth"), + ), patch( + "hermes_cli.auth.resolve_xai_oauth_runtime_credentials", + return_value={}, + ), patch( + "hermes_cli.config.load_env", + return_value={"XAI_API_KEY": "dotenv-secret"}, + ): + creds = xai_http.resolve_xai_http_credentials() + finally: + importlib.reload(xai_http) + + assert creds["api_key"] == "dotenv-secret" + def test_explicit_groq_sees_dotenv(self): from tools import transcription_tools as tt diff --git a/tools/xai_http.py b/tools/xai_http.py index 216a51ff10db0..848ad8fc748b3 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -5,12 +5,6 @@ import os from typing import Dict -try: - from hermes_cli.config import get_env_value as _hermes_get_env_value -except Exception: - _hermes_get_env_value = None - - def get_env_value(name: str, default=None): """Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``. @@ -18,10 +12,14 @@ def get_env_value(name: str, default=None): ``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the xAI credential resolver. """ - if _hermes_get_env_value is not None: + try: + from hermes_cli.config import get_env_value as _hermes_get_env_value + value = _hermes_get_env_value(name) if value is not None: return value + except Exception: + pass return os.environ.get(name, default)