mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
When a Hermes-managed node/npm/npx shim exists but fails --version, redownload the pinned nodejs.org bundle under HERMES_HOME/node and retry. Do not fall back to system npm on PATH when a managed tree is present. POSIX heal probes node, npm, and npx (npm can break while node still runs).
611 lines
26 KiB
Python
611 lines
26 KiB
Python
"""Tests for hermes_constants module."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_constants
|
|
from hermes_constants import (
|
|
VALID_REASONING_EFFORTS,
|
|
agent_browser_runnable,
|
|
find_hermes_node_executable,
|
|
find_node_executable,
|
|
find_node_executable_on_path,
|
|
get_default_hermes_root,
|
|
get_hermes_home,
|
|
heal_hermes_managed_node,
|
|
hermes_managed_node_tree_present,
|
|
iter_hermes_node_dirs,
|
|
is_container,
|
|
node_tool_runnable,
|
|
parse_reasoning_effort,
|
|
secure_parent_dir,
|
|
with_hermes_node_path,
|
|
)
|
|
|
|
|
|
class TestGetDefaultHermesRoot:
|
|
"""Tests for get_default_hermes_root() — Docker/custom deployment awareness."""
|
|
|
|
def test_no_hermes_home_returns_native(self, tmp_path, monkeypatch):
|
|
"""When HERMES_HOME is not set, returns ~/.hermes."""
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
assert get_default_hermes_root() == tmp_path / ".hermes"
|
|
|
|
def test_hermes_home_is_native(self, tmp_path, monkeypatch):
|
|
"""When HERMES_HOME = ~/.hermes, returns ~/.hermes."""
|
|
native = tmp_path / ".hermes"
|
|
native.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(native))
|
|
assert get_default_hermes_root() == native
|
|
|
|
def test_hermes_home_is_profile(self, tmp_path, monkeypatch):
|
|
"""When HERMES_HOME is a profile under ~/.hermes, returns ~/.hermes."""
|
|
native = tmp_path / ".hermes"
|
|
profile = native / "profiles" / "coder"
|
|
profile.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(profile))
|
|
assert get_default_hermes_root() == native
|
|
|
|
def test_hermes_home_is_docker(self, tmp_path, monkeypatch):
|
|
"""When HERMES_HOME points outside ~/.hermes (Docker), returns HERMES_HOME."""
|
|
docker_home = tmp_path / "opt" / "data"
|
|
docker_home.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
|
assert get_default_hermes_root() == docker_home
|
|
|
|
def test_hermes_home_is_custom_path(self, tmp_path, monkeypatch):
|
|
"""Any HERMES_HOME outside ~/.hermes is treated as the root."""
|
|
custom = tmp_path / "my-hermes-data"
|
|
custom.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(custom))
|
|
assert get_default_hermes_root() == custom
|
|
|
|
def test_docker_profile_active(self, tmp_path, monkeypatch):
|
|
"""When a Docker profile is active (HERMES_HOME=<root>/profiles/<name>),
|
|
returns the Docker root, not the profile dir."""
|
|
docker_root = tmp_path / "opt" / "data"
|
|
profile = docker_root / "profiles" / "coder"
|
|
profile.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(profile))
|
|
assert get_default_hermes_root() == docker_root
|
|
|
|
def test_no_hermes_home_returns_localappdata_root_on_windows(self, tmp_path, monkeypatch):
|
|
"""Native Windows falls back to %LOCALAPPDATA%\\hermes, not ~/.hermes."""
|
|
local_appdata = tmp_path / "LocalAppData"
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
|
|
assert get_default_hermes_root() == local_appdata / "hermes"
|
|
|
|
def test_no_hermes_home_uses_windows_path_when_localappdata_missing(self, tmp_path, monkeypatch):
|
|
"""Windows fallback still uses AppData/Local/hermes without LOCALAPPDATA."""
|
|
home = tmp_path / "Home"
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
|
|
assert get_default_hermes_root() == home / "AppData" / "Local" / "hermes"
|
|
|
|
|
|
class TestGetHermesHome:
|
|
"""Tests for get_hermes_home() platform-aware fallback."""
|
|
|
|
def test_windows_fallback_uses_localappdata(self, tmp_path, monkeypatch):
|
|
"""When HERMES_HOME is unset on Windows, use %LOCALAPPDATA%\\hermes."""
|
|
local_appdata = tmp_path / "LocalAppData"
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False)
|
|
|
|
assert get_hermes_home() == local_appdata / "hermes"
|
|
|
|
|
|
class TestHermesManagedNode:
|
|
def test_windows_node_dir_prefers_portable_root(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
node_dir = home / "node"
|
|
bin_dir = node_dir / "bin"
|
|
node_dir.mkdir(parents=True)
|
|
bin_dir.mkdir()
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
assert iter_hermes_node_dirs() == [node_dir, bin_dir]
|
|
|
|
def test_windows_finds_npm_cmd_before_path(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
node_dir = home / "node"
|
|
node_dir.mkdir(parents=True)
|
|
npm_cmd = node_dir / "npm.cmd"
|
|
npm_cmd.write_text("@echo off\n")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(hermes_constants, "node_tool_runnable", lambda path: True)
|
|
|
|
assert find_hermes_node_executable("npm") == str(npm_cmd)
|
|
|
|
def test_windows_path_fallback_prefers_npm_cmd(self, tmp_path, monkeypatch):
|
|
bin_dir = tmp_path / "nodejs"
|
|
bin_dir.mkdir()
|
|
extensionless = bin_dir / "npm"
|
|
powershell = bin_dir / "npm.ps1"
|
|
npm_cmd = bin_dir / "npm.cmd"
|
|
extensionless.write_text("#!/usr/bin/env node\n")
|
|
powershell.write_text("Write-Output npm\n")
|
|
npm_cmd.write_text("@echo off\n")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("PATH", str(bin_dir))
|
|
|
|
assert find_node_executable_on_path("npm") == str(npm_cmd)
|
|
|
|
def test_windows_node_executable_falls_back_to_safe_path_shim(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
home.mkdir()
|
|
bin_dir = tmp_path / "nodejs"
|
|
bin_dir.mkdir()
|
|
extensionless = bin_dir / "npm"
|
|
npm_cmd = bin_dir / "npm.cmd"
|
|
extensionless.write_text("#!/usr/bin/env node\n")
|
|
npm_cmd.write_text("@echo off\n")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("PATH", str(bin_dir))
|
|
|
|
assert find_node_executable("npm") == str(npm_cmd)
|
|
|
|
def test_windows_skips_broken_managed_npm_without_path_fallback(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
managed_npm = home / "node" / "npm.cmd"
|
|
managed_npm.parent.mkdir(parents=True)
|
|
managed_npm.write_text("@echo off\n")
|
|
bin_dir = tmp_path / "nodejs"
|
|
bin_dir.mkdir()
|
|
path_npm = bin_dir / "npm.cmd"
|
|
path_npm.write_text("@echo off\n")
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("PATH", str(bin_dir))
|
|
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
|
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False)
|
|
monkeypatch.setattr(
|
|
hermes_constants,
|
|
"node_tool_runnable",
|
|
lambda path: False,
|
|
)
|
|
|
|
assert hermes_managed_node_tree_present() is True
|
|
assert find_node_executable("npm") is None
|
|
assert find_node_executable("npm") != str(path_npm)
|
|
|
|
def test_with_hermes_node_path_prepends_existing_managed_dirs(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
node_dir = home / "node"
|
|
bin_dir = node_dir / "bin"
|
|
node_dir.mkdir(parents=True)
|
|
bin_dir.mkdir()
|
|
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
env = with_hermes_node_path({"PATH": "system-node"})
|
|
parts = env["PATH"].split(os.pathsep)
|
|
|
|
assert parts[:2] == [str(node_dir), str(bin_dir)]
|
|
assert parts[-1] == "system-node"
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX shell stubs; Windows uses .cmd shims")
|
|
class TestNodeToolRunnable:
|
|
"""node_tool_runnable() rejects broken Hermes-managed npm/node wrappers."""
|
|
|
|
def _stub(self, tmp_path, name, body, mode=0o755):
|
|
path = tmp_path / name
|
|
path.write_text(body)
|
|
path.chmod(mode)
|
|
return path
|
|
|
|
def test_none_and_empty_rejected(self):
|
|
assert node_tool_runnable(None) is False
|
|
assert node_tool_runnable("") is False
|
|
|
|
def test_runnable_stub_accepted(self, tmp_path):
|
|
good = self._stub(tmp_path, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n")
|
|
assert node_tool_runnable(str(good)) is True
|
|
|
|
def test_nonzero_exit_rejected(self, tmp_path):
|
|
bad = self._stub(tmp_path, "npm", "#!/bin/sh\nexit 1\n")
|
|
assert node_tool_runnable(str(bad)) is False
|
|
|
|
def test_broken_managed_npm_heals_when_node_still_runs(self, tmp_path, monkeypatch):
|
|
"""npm can fail while node --version still succeeds (missing lib/cli.js)."""
|
|
profile_home = tmp_path / "profiles" / "assistant"
|
|
managed_bin = profile_home / "node" / "bin"
|
|
managed_bin.mkdir(parents=True)
|
|
self._stub(managed_bin, "node", "#!/bin/sh\necho '22.0.0'\nexit 0\n")
|
|
broken_npm = self._stub(managed_bin, "npm", "#!/bin/sh\nexit 1\n")
|
|
heal_called = {"value": False}
|
|
|
|
system_bin = tmp_path / "system-bin"
|
|
system_bin.mkdir()
|
|
self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
monkeypatch.setenv("PATH", str(system_bin))
|
|
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
|
|
|
def _heal():
|
|
heal_called["value"] = True
|
|
broken_npm.write_text("#!/bin/sh\necho '22.0.0'\nexit 0\n")
|
|
broken_npm.chmod(0o755)
|
|
return True
|
|
|
|
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
|
|
|
|
resolved = find_node_executable("npm")
|
|
assert heal_called["value"] is True
|
|
assert resolved == str(broken_npm)
|
|
assert resolved != str(system_bin / "npm")
|
|
|
|
def test_broken_managed_npm_heals_instead_of_path_fallback(self, tmp_path, monkeypatch):
|
|
profile_home = tmp_path / "profiles" / "assistant"
|
|
managed_bin = profile_home / "node" / "bin"
|
|
managed_bin.mkdir(parents=True)
|
|
broken_npm = self._stub(managed_bin, "npm", "#!/bin/sh\nexit 1\n")
|
|
healed_npm = self._stub(managed_bin, "npm", "#!/bin/sh\necho '22.0.0'\nexit 0\n")
|
|
|
|
system_bin = tmp_path / "system-bin"
|
|
system_bin.mkdir()
|
|
good_npm = self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
monkeypatch.setenv("PATH", str(system_bin))
|
|
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
|
|
|
def _heal():
|
|
broken_npm.write_text(healed_npm.read_text())
|
|
broken_npm.chmod(0o755)
|
|
return True
|
|
|
|
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
|
|
|
|
assert find_hermes_node_executable("npm") == str(healed_npm)
|
|
assert find_node_executable("npm") == str(healed_npm)
|
|
assert find_node_executable("npm") != str(good_npm)
|
|
|
|
def test_broken_managed_npm_returns_none_when_heal_fails(self, tmp_path, monkeypatch):
|
|
profile_home = tmp_path / "profiles" / "assistant"
|
|
managed_bin = profile_home / "node" / "bin"
|
|
managed_bin.mkdir(parents=True)
|
|
self._stub(managed_bin, "npm", "#!/bin/sh\nexit 1\n")
|
|
|
|
system_bin = tmp_path / "system-bin"
|
|
system_bin.mkdir()
|
|
self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
monkeypatch.setenv("PATH", str(system_bin))
|
|
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
|
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False)
|
|
|
|
assert find_node_executable("npm") is None
|
|
|
|
def test_healthy_managed_npm_still_preferred(self, tmp_path, monkeypatch):
|
|
profile_home = tmp_path / "profiles" / "assistant"
|
|
managed_bin = profile_home / "node" / "bin"
|
|
managed_bin.mkdir(parents=True)
|
|
managed_npm = self._stub(managed_bin, "npm", "#!/bin/sh\necho '22.0.0'\nexit 0\n")
|
|
|
|
system_bin = tmp_path / "system-bin"
|
|
system_bin.mkdir()
|
|
self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
monkeypatch.setenv("PATH", str(system_bin))
|
|
|
|
assert find_node_executable("npm") == str(managed_npm)
|
|
|
|
|
|
class TestIsContainer:
|
|
"""Tests for is_container() — Docker/Podman detection."""
|
|
|
|
def _reset_cache(self, monkeypatch):
|
|
"""Reset the cached detection result before each test."""
|
|
monkeypatch.setattr(hermes_constants, "_container_detected", None)
|
|
|
|
def test_detects_dockerenv(self, monkeypatch, tmp_path):
|
|
"""/.dockerenv triggers container detection."""
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: p == "/.dockerenv")
|
|
assert is_container() is True
|
|
|
|
def test_detects_containerenv(self, monkeypatch, tmp_path):
|
|
"""/run/.containerenv triggers container detection (Podman)."""
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: p == "/run/.containerenv")
|
|
assert is_container() is True
|
|
|
|
def test_detects_cgroup_docker(self, monkeypatch, tmp_path):
|
|
"""/proc/1/cgroup containing 'docker' triggers detection."""
|
|
import builtins
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
cgroup_file = tmp_path / "cgroup"
|
|
cgroup_file.write_text("12:memory:/docker/abc123\n")
|
|
_real_open = builtins.open
|
|
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
|
|
assert is_container() is True
|
|
|
|
def test_negative_case(self, monkeypatch, tmp_path):
|
|
"""Returns False on a regular Linux host."""
|
|
import builtins
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
cgroup_file = tmp_path / "cgroup"
|
|
cgroup_file.write_text("12:memory:/\n")
|
|
mountinfo_file = tmp_path / "mountinfo"
|
|
mountinfo_file.write_text("22 21 0:20 / /sys rw shared:7 - sysfs sysfs rw\n")
|
|
_real_open = builtins.open
|
|
|
|
def _fake_open(p, *a, **kw):
|
|
if p == "/proc/1/cgroup":
|
|
return _real_open(str(cgroup_file), *a, **kw)
|
|
if p == "/proc/self/mountinfo":
|
|
return _real_open(str(mountinfo_file), *a, **kw)
|
|
return _real_open(p, *a, **kw)
|
|
|
|
monkeypatch.setattr("builtins.open", _fake_open)
|
|
assert is_container() is False
|
|
|
|
def test_detects_kubernetes_env(self, monkeypatch):
|
|
"""KUBERNETES_SERVICE_HOST env var triggers detection (k8s/k3s pod)."""
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1")
|
|
assert is_container() is True
|
|
|
|
def test_detects_cgroup_kubepods(self, monkeypatch, tmp_path):
|
|
"""/proc/1/cgroup containing 'kubepods' triggers detection."""
|
|
import builtins
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
cgroup_file = tmp_path / "cgroup"
|
|
cgroup_file.write_text("12:memory:/kubepods/besteffort/podabc\n")
|
|
_real_open = builtins.open
|
|
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
|
|
assert is_container() is True
|
|
|
|
def test_detects_cgroup_v2_via_mountinfo(self, monkeypatch, tmp_path):
|
|
"""cgroup v2 (0::/ only) falls back to containerd marker in mountinfo."""
|
|
import builtins
|
|
self._reset_cache(monkeypatch)
|
|
monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
cgroup_file = tmp_path / "cgroup"
|
|
cgroup_file.write_text("0::/\n") # cgroup v2 — no runtime marker
|
|
mountinfo_file = tmp_path / "mountinfo"
|
|
mountinfo_file.write_text(
|
|
"1234 1233 0:42 /containerd/.../rootfs / rw - overlay overlay rw\n"
|
|
)
|
|
_real_open = builtins.open
|
|
|
|
def _fake_open(p, *a, **kw):
|
|
if p == "/proc/1/cgroup":
|
|
return _real_open(str(cgroup_file), *a, **kw)
|
|
if p == "/proc/self/mountinfo":
|
|
return _real_open(str(mountinfo_file), *a, **kw)
|
|
return _real_open(p, *a, **kw)
|
|
|
|
monkeypatch.setattr("builtins.open", _fake_open)
|
|
assert is_container() is True
|
|
|
|
def test_caches_result(self, monkeypatch):
|
|
"""Second call uses cached value without re-probing."""
|
|
monkeypatch.setattr(hermes_constants, "_container_detected", True)
|
|
assert is_container() is True
|
|
# Even if we make os.path.exists return False, cached value wins
|
|
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
|
assert is_container() is True
|
|
|
|
|
|
class TestParseReasoningEffort:
|
|
"""Tests for parse_reasoning_effort() — string → reasoning config dict."""
|
|
|
|
@pytest.mark.parametrize("value", ["", " ", "\t", "\n"])
|
|
def test_empty_or_whitespace_returns_none(self, value):
|
|
"""Empty / whitespace-only input falls back to caller default (None)."""
|
|
assert parse_reasoning_effort(value) is None
|
|
|
|
def test_none_disables_reasoning(self):
|
|
"""The literal "none" disables reasoning explicitly."""
|
|
assert parse_reasoning_effort("none") == {"enabled": False}
|
|
|
|
@pytest.mark.parametrize("level", list(VALID_REASONING_EFFORTS))
|
|
def test_each_valid_level(self, level):
|
|
"""Every level listed in VALID_REASONING_EFFORTS is accepted as-is."""
|
|
assert parse_reasoning_effort(level) == {"enabled": True, "effort": level}
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw, expected_effort",
|
|
[
|
|
("MEDIUM", "medium"),
|
|
("High", "high"),
|
|
(" low ", "low"),
|
|
("\tXHIGH\n", "xhigh"),
|
|
("None", False),
|
|
],
|
|
)
|
|
def test_case_and_whitespace_normalized(self, raw, expected_effort):
|
|
"""Mixed case and surrounding whitespace are normalized before lookup."""
|
|
result = parse_reasoning_effort(raw)
|
|
if expected_effort is False:
|
|
assert result == {"enabled": False}
|
|
else:
|
|
assert result == {"enabled": True, "effort": expected_effort}
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["bogus", "very-high", "max", "0", "off", "true", "default"],
|
|
)
|
|
def test_unknown_levels_return_none(self, value):
|
|
"""Unrecognized strings fall back to the caller default (None)."""
|
|
assert parse_reasoning_effort(value) is None
|
|
|
|
def test_known_supported_levels_are_documented(self):
|
|
"""Guard against silently dropping a documented level.
|
|
|
|
The docstring promises "minimal", "low", "medium", "high", "xhigh".
|
|
If someone removes one from VALID_REASONING_EFFORTS without updating
|
|
the docstring, this test will fail and force the call out.
|
|
"""
|
|
documented = {"minimal", "low", "medium", "high", "xhigh"}
|
|
assert documented.issubset(set(VALID_REASONING_EFFORTS))
|
|
|
|
|
|
class TestSecureParentDir:
|
|
"""Tests for secure_parent_dir() — prevents chmod on / or top-level dirs."""
|
|
|
|
def test_safe_path_calls_chmod(self, tmp_path, monkeypatch):
|
|
"""Normal nested path (depth >= 3) should call os.chmod."""
|
|
safe_dir = tmp_path / "home" / "user" / ".hermes"
|
|
safe_dir.mkdir(parents=True)
|
|
target = safe_dir / "auth.json"
|
|
target.touch()
|
|
|
|
called_with = []
|
|
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
|
|
|
secure_parent_dir(target)
|
|
assert len(called_with) == 1
|
|
assert called_with[0] == (str(safe_dir), 0o700)
|
|
|
|
def test_root_dir_skipped(self, monkeypatch):
|
|
"""Parent resolving to / must NOT be chmod'd."""
|
|
called_with = []
|
|
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
|
|
|
# Path("/foo").parent == Path("/")
|
|
secure_parent_dir(Path("/foo"))
|
|
assert called_with == []
|
|
|
|
def test_top_level_dir_skipped(self, monkeypatch):
|
|
"""Parent resolving to a top-level dir (depth 2) must NOT be chmod'd."""
|
|
called_with = []
|
|
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
|
|
|
# Path("/usr/foo").parent == Path("/usr") — depth 2
|
|
secure_parent_dir(Path("/usr/foo"))
|
|
assert called_with == []
|
|
|
|
def test_two_component_path_skipped(self, monkeypatch):
|
|
"""Parent with < 3 resolved parts must NOT be chmod'd.
|
|
|
|
Uses monkeypatch to avoid macOS firmlink resolution of /home.
|
|
"""
|
|
called_with = []
|
|
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
|
|
|
# Mock Path.resolve to return a short path regardless of OS quirks
|
|
original_resolve = Path.resolve
|
|
def mock_resolve(self):
|
|
if str(self) == "/x/y":
|
|
return Path("/x")
|
|
return original_resolve(self)
|
|
monkeypatch.setattr(Path, "resolve", mock_resolve)
|
|
|
|
secure_parent_dir(Path("/x/y"))
|
|
assert called_with == []
|
|
|
|
def test_oserror_suppressed(self, tmp_path, monkeypatch):
|
|
"""OSError from chmod should be silently caught."""
|
|
safe_dir = tmp_path / "a" / "b" / "c"
|
|
safe_dir.mkdir(parents=True)
|
|
target = safe_dir / "file.json"
|
|
target.touch()
|
|
|
|
def raise_oserror(p, m):
|
|
raise OSError("permission denied")
|
|
|
|
monkeypatch.setattr(os, "chmod", raise_oserror)
|
|
# Should not raise
|
|
secure_parent_dir(target)
|
|
|
|
def test_symlink_resolved(self, tmp_path, monkeypatch):
|
|
"""Symlinks should be resolved before checking depth."""
|
|
real_dir = tmp_path / "a" / "b"
|
|
real_dir.mkdir(parents=True)
|
|
target = real_dir / "file.json"
|
|
target.touch()
|
|
|
|
# Create a symlink with fewer path components
|
|
link = tmp_path / "link"
|
|
link.symlink_to(real_dir)
|
|
link_target = link / "file.json"
|
|
|
|
called_with = []
|
|
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
|
|
|
# Even though /tmp/link has only 3 parts, the resolved path has 4
|
|
# The resolved parent (real_dir) has depth 4, so it should be chmod'd
|
|
secure_parent_dir(link_target)
|
|
assert len(called_with) == 1
|
|
assert called_with[0] == (str(real_dir), 0o700)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX shell stubs; Windows uses .cmd shims")
|
|
class TestAgentBrowserRunnable:
|
|
"""agent_browser_runnable() validates the resolved CLI actually runs.
|
|
|
|
Regression coverage for issue #48521: a dangling global symlink left by
|
|
agent-browser's npm postinstall is reported by ``which`` but fails at exec
|
|
with exit 127, silently breaking every browser tool. The validator must
|
|
reject it (and other non-runnable candidates) so callers fall through.
|
|
"""
|
|
|
|
def _stub(self, tmp_path, name, body, mode=0o755):
|
|
p = tmp_path / name
|
|
p.write_text(body)
|
|
p.chmod(mode)
|
|
return p
|
|
|
|
def test_none_and_empty_rejected(self):
|
|
assert agent_browser_runnable(None) is False
|
|
assert agent_browser_runnable("") is False
|
|
|
|
def test_dangling_symlink_rejected(self, tmp_path):
|
|
link = tmp_path / "agent-browser"
|
|
link.symlink_to(tmp_path / "does-not-exist")
|
|
# exists() follows the link → False, so it's rejected without exec.
|
|
assert agent_browser_runnable(str(link)) is False
|
|
|
|
def test_runnable_binary_accepted(self, tmp_path):
|
|
good = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho 'agent-browser 0.27.1'\nexit 0\n")
|
|
assert agent_browser_runnable(str(good)) is True
|
|
|
|
def test_nonzero_exit_rejected(self, tmp_path):
|
|
bad = self._stub(tmp_path, "agent-browser", "#!/bin/sh\nexit 127\n")
|
|
assert agent_browser_runnable(str(bad)) is False
|
|
|
|
def test_not_executable_rejected(self, tmp_path):
|
|
noexec = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho hi\n", mode=0o644)
|
|
assert agent_browser_runnable(str(noexec)) is False
|
|
|
|
def test_npx_fallback_form_accepted(self):
|
|
# The "npx agent-browser" command form is not a real file; npx resolves
|
|
# the package at run time, so the validator trusts it without stat.
|
|
assert agent_browser_runnable("npx agent-browser") is True
|
|
assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True
|