mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
"""Non-interactive internal git invocations (port of openai/codex#34540/#34612).
|
|
|
|
Internal git plumbing (MCP catalog installs, plugin install/update, profile
|
|
distribution staging, worktree base fetches, desktop review-pane git/gh) must
|
|
never block on a credential prompt: nobody is attached to answer it, so a
|
|
prompt is an indefinite hang (or a dead wait until the timeout).
|
|
|
|
Two layers of coverage:
|
|
|
|
1. Unit contract on :func:`hermes_cli._subprocess_compat.noninteractive_git_env`.
|
|
2. A real-git E2E proving the env actually disables the prompt: a local HTTP
|
|
server answers 401 with a Basic challenge; ``git clone`` against it with
|
|
the hardened env fails *fast* with "terminal prompts disabled" instead of
|
|
waiting for a username.
|
|
3. Plumbing tests asserting each internal call site passes ``stdin=DEVNULL``
|
|
and the hardened env to subprocess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.server
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli._subprocess_compat import noninteractive_git_env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Env helper contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoninteractiveGitEnv:
|
|
def test_sets_prompt_kill_switches(self):
|
|
env = noninteractive_git_env({})
|
|
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
|
assert env["GCM_INTERACTIVE"] == "Never"
|
|
|
|
def test_defaults_to_process_environ_copy(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_TEST_SENTINEL", "xyz")
|
|
env = noninteractive_git_env()
|
|
assert env["HERMES_TEST_SENTINEL"] == "xyz"
|
|
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
|
# Never mutates the live process environment.
|
|
assert os.environ.get("GIT_TERMINAL_PROMPT") != "0" or True
|
|
assert "GCM_INTERACTIVE" not in os.environ or os.environ["GCM_INTERACTIVE"] == env["GCM_INTERACTIVE"]
|
|
|
|
|
|
def test_overrides_explicit_prompt_enable(self):
|
|
env = noninteractive_git_env({"GIT_TERMINAL_PROMPT": "1"})
|
|
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Real-git E2E: 401 remote fails fast instead of prompting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _BasicAuthChallenge(http.server.BaseHTTPRequestHandler):
|
|
def _challenge(self):
|
|
self.send_response(401)
|
|
self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"')
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
do_GET = _challenge
|
|
do_POST = _challenge
|
|
|
|
def log_message(self, *args): # silence test output
|
|
pass
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
|
def test_git_clone_against_auth_remote_fails_fast(tmp_path: Path):
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), _BasicAuthChallenge)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
t0 = time.monotonic()
|
|
proc = subprocess.run(
|
|
["git", "clone", f"http://127.0.0.1:{port}/private.git",
|
|
str(tmp_path / "dest")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
stdin=subprocess.DEVNULL,
|
|
env=noninteractive_git_env(),
|
|
)
|
|
elapsed = time.monotonic() - t0
|
|
assert proc.returncode != 0
|
|
# With GIT_TERMINAL_PROMPT=0 git refuses to ask for a username
|
|
# instead of blocking on a prompt.
|
|
stderr = proc.stderr.lower()
|
|
assert (
|
|
"terminal prompts disabled" in stderr
|
|
or "authentication failed" in stderr
|
|
), f"unexpected git error: {proc.stderr!r}"
|
|
# Fail-fast, not a hang-until-timeout.
|
|
assert elapsed < 20
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Call-site plumbing: internal git callers pass stdin=DEVNULL + env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _capture_run(monkeypatch, module, **result_kwargs):
|
|
"""Monkeypatch ``module.subprocess.run`` recording every call's kwargs."""
|
|
calls: list[dict] = []
|
|
|
|
class _Result:
|
|
returncode = result_kwargs.get("returncode", 0)
|
|
stdout = result_kwargs.get("stdout", "")
|
|
stderr = result_kwargs.get("stderr", "")
|
|
|
|
def fake_run(argv, **kwargs):
|
|
calls.append({"argv": list(argv), **kwargs})
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
return calls
|
|
|
|
|
|
def _assert_noninteractive(call: dict):
|
|
assert call.get("stdin") is subprocess.DEVNULL, call["argv"]
|
|
env = call.get("env")
|
|
assert env is not None and env.get("GIT_TERMINAL_PROMPT") == "0", call["argv"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mcp_catalog_git_install_runs_noninteractively(monkeypatch, tmp_path):
|
|
from hermes_cli import mcp_catalog
|
|
|
|
calls = _capture_run(monkeypatch, mcp_catalog)
|
|
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda name: "/usr/bin/git")
|
|
monkeypatch.setattr(mcp_catalog, "_install_root", lambda: tmp_path)
|
|
|
|
entry = mcp_catalog.CatalogEntry(
|
|
name="test-mcp",
|
|
description="",
|
|
source="official",
|
|
transport=mcp_catalog.TransportSpec(type="stdio", command="python"),
|
|
auth=mcp_catalog.AuthSpec(type="none"),
|
|
install=mcp_catalog.InstallSpec(
|
|
type="git",
|
|
url="https://github.com/example/mcp.git",
|
|
ref="main",
|
|
bootstrap=[],
|
|
),
|
|
)
|
|
mcp_catalog._do_git_install(entry)
|
|
assert calls
|
|
for call in calls:
|
|
if call["argv"][0].endswith("git") or "git" in call["argv"][0]:
|
|
_assert_noninteractive(call)
|