mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Port from openai/codex#34540 / #34612 ("detach non-interactive subprocesses from stdin"): internal git invocations that run with nobody attached — MCP catalog installs, plugin install/update, profile distribution staging, worktree base fetches, and the desktop review pane's git/gh backend — could hang on a credential prompt when a remote is private, misconfigured, or requires auth. git prompts on the inherited terminal (or via Git Credential Manager on Windows), so the operation silently waits until its timeout, or forever at sites without one (mcp_catalog clones have no timeout at all and inherit the parent terminal). - Add noninteractive_git_env() to hermes_cli/_subprocess_compat.py: GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=Never on a copy of the environment; GIT_ASKPASS/SSH_ASKPASS deliberately preserved so working non-interactive auth still succeeds. - Wire it + stdin=DEVNULL into: mcp_catalog._do_git_install (clone/ checkout), plugins_cmd (clone + pull), profile_distribution._git_clone, web_git._git/_gh (gh also gets GH_PROMPT_DISABLED=1), and cli.py's worktree base fetch helper. - Tests: env contract, a real-git E2E against a local 401 Basic-auth HTTP server proving fail-fast ("terminal prompts disabled") instead of a hang, and per-call-site plumbing assertions. Sabotage-verified: removing the env from web_git._git fails the site test.
218 lines
7.7 KiB
Python
218 lines
7.7 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_does_not_mutate_base_mapping(self):
|
|
base = {"PATH": "/usr/bin"}
|
|
env = noninteractive_git_env(base)
|
|
assert "GIT_TERMINAL_PROMPT" not in base
|
|
assert env is not base
|
|
|
|
def test_preserves_working_askpass(self):
|
|
"""A configured askpass helper is a *working* non-interactive auth
|
|
path — the env must not strip it."""
|
|
base = {"GIT_ASKPASS": "/usr/local/bin/my-askpass", "SSH_ASKPASS": "/x"}
|
|
env = noninteractive_git_env(base)
|
|
assert env["GIT_ASKPASS"] == "/usr/local/bin/my-askpass"
|
|
assert env["SSH_ASKPASS"] == "/x"
|
|
|
|
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_web_git_runs_noninteractively(monkeypatch, tmp_path):
|
|
from hermes_cli import web_git
|
|
|
|
calls = _capture_run(monkeypatch, web_git)
|
|
web_git._git(str(tmp_path), ["fetch", "origin", "main"])
|
|
assert calls
|
|
_assert_noninteractive(calls[0])
|
|
|
|
|
|
def test_web_gh_runs_noninteractively(monkeypatch, tmp_path):
|
|
from hermes_cli import web_git
|
|
|
|
monkeypatch.setattr(web_git.shutil, "which", lambda name: "/usr/bin/gh")
|
|
calls = _capture_run(monkeypatch, web_git)
|
|
web_git._gh(str(tmp_path), ["auth", "status"])
|
|
assert calls
|
|
_assert_noninteractive(calls[0])
|
|
assert calls[0]["env"].get("GH_PROMPT_DISABLED") == "1"
|
|
|
|
|
|
def test_plugin_git_pull_runs_noninteractively(monkeypatch, tmp_path):
|
|
from hermes_cli import plugins_cmd
|
|
|
|
monkeypatch.setattr(plugins_cmd, "_resolve_git_executable", lambda: "git")
|
|
calls = _capture_run(monkeypatch, plugins_cmd)
|
|
plugins_cmd._git_pull_plugin_dir(tmp_path)
|
|
assert calls
|
|
_assert_noninteractive(calls[0])
|
|
|
|
|
|
def test_profile_distribution_clone_runs_noninteractively(monkeypatch, tmp_path):
|
|
from hermes_cli import profile_distribution
|
|
|
|
calls = _capture_run(monkeypatch, profile_distribution)
|
|
profile_distribution._git_clone(
|
|
"https://github.com/example/repo", tmp_path / "dest"
|
|
)
|
|
assert calls
|
|
_assert_noninteractive(calls[0])
|
|
|
|
|
|
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)
|