fix(cli): make plugin clone timeout configurable

This commit is contained in:
Gille
2026-09-23 20:11:11 -06:00
committed by Teknium
parent 8ae1dc11a0
commit 5dbbc9a9c0
5 changed files with 86 additions and 4 deletions

View File

@@ -37,6 +37,14 @@ database:
runtime:
nofile_soft_limit: 4096
# =============================================================================
# Plugin Installation
# =============================================================================
plugins:
# Deadline for each Git clone or pinned-commit fetch. Default: 300 seconds.
# Raise this for large repositories or slow connections. Maximum: 3600.
clone_timeout_seconds: 300
# =============================================================================
# Model Configuration
# =============================================================================

View File

@@ -1684,6 +1684,9 @@ DEFAULT_CONFIG = {
# Plugin system. `enabled`/`disabled` lists are written by `hermes plugins enable|disable` and
# deliberately omitted here so an empty default never clobbers a user allow-list.
"plugins": {
# Deadline (seconds) for one plugin Git clone or pinned-commit fetch. Slow repositories may
# need more time; each network operation is capped at one hour.
"clone_timeout_seconds": 300,
# Wall-clock cap (seconds) for one in-process Python plugin hook callback; shell hooks keep
# their own per-entry `timeout`. 0 = no cap (sync call on agent thread). Max 600.
"hook_callback_timeout": 30,

View File

@@ -26,6 +26,8 @@ from hermes_cli.secret_prompt import masked_secret_prompt
from utils import atomic_write_text, rmtree_readonly
logger = logging.getLogger(__name__)
_DEFAULT_CLONE_TIMEOUT_SECONDS = 300
_MAX_CLONE_TIMEOUT_SECONDS = 3600
@functools.lru_cache(maxsize=1)
@@ -103,6 +105,19 @@ def _config_value(*keys: str, default: Any) -> Any:
return default
def _clone_timeout_seconds() -> int:
"""Deadline for plugin clone and pinned fetch, scoped to the active profile."""
value = _config_value("plugins", "clone_timeout_seconds", default=_DEFAULT_CLONE_TIMEOUT_SECONDS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
logger.warning("plugins.clone_timeout_seconds must be a positive integer; using %ss",
_DEFAULT_CLONE_TIMEOUT_SECONDS)
return _DEFAULT_CLONE_TIMEOUT_SECONDS
if value > _MAX_CLONE_TIMEOUT_SECONDS:
logger.warning("plugins.clone_timeout_seconds exceeds %ss; clamping", _MAX_CLONE_TIMEOUT_SECONDS)
return _MAX_CLONE_TIMEOUT_SECONDS
return value
def _config_name_set(*keys: str) -> set:
"""A list-valued config key as a set (empty on any failure or non-list)."""
value = _config_value(*keys, default=[])
@@ -597,15 +612,18 @@ def _git_resolve_commit(repo: Path, git_exe: str, revision: str) -> str:
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
fetch_timeout = _clone_timeout_seconds()
for verb, args, failure_prefix in (
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
):
try:
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
auth_url=source_url if verb == "fetch" else "")
auth_url=source_url if verb == "fetch" else "",
timeout=fetch_timeout if verb == "fetch" else 60)
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after 60 seconds.") from exc
timeout = fetch_timeout if verb == "fetch" else 60
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after {timeout} seconds.") from exc
actual = _git_head_revision(repo, git_exe)
if actual != _git_resolve_commit(repo, git_exe, revision):
raise PluginOperationError(
@@ -666,13 +684,15 @@ def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -
git_exe = _resolve_git_executable()
if not git_exe:
raise PluginOperationError("git is not installed or not in PATH.")
clone_timeout = _clone_timeout_seconds()
clone_args = ["clone", "--depth", "1", *(["--no-checkout"] if revision else []), git_url, str(tmp_clone)]
try:
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url)
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url,
timeout=clone_timeout)
except FileNotFoundError as e:
raise PluginOperationError("git is not installed or not in PATH.") from e
except subprocess.TimeoutExpired as e:
raise PluginOperationError("Git clone timed out after 60 seconds.") from e
raise PluginOperationError(f"Git clone timed out after {clone_timeout} seconds.") from e
if result.returncode != 0:
raise PluginOperationError(_clone_failure_message(git_url, _safe_git_error(result, git_url)))
_scrub_cloned_origin(tmp_clone, git_exe, git_url)

View File

@@ -169,6 +169,53 @@ def test_subdir_pin_records_source_identity_and_installs_requested_tree(
}
def test_clone_timeout_applies_to_real_pinned_subdir_install(monkeypatch, tmp_path):
from hermes_cli import plugins_cmd
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
home.mkdir()
(home / "config.yaml").write_text("plugins:\n clone_timeout_seconds: 137\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
run_git = plugins_cmd._run_plugin_git
calls = []
def record_git(git_exe, target, *args, **kwargs):
calls.append((args[0], kwargs.get("timeout")))
return run_git(git_exe, target, *args, **kwargs)
monkeypatch.setattr(plugins_cmd, "_run_plugin_git", record_git)
target, _manifest, _name = plugins_cmd._install_plugin_core(
repo.as_uri(), force=False, ref=old_sha
)
assert _git(target, "rev-parse", "HEAD") == old_sha
assert ("clone", 137) in calls
assert ("fetch", 137) in calls
assert ("checkout", 60) in calls
def test_clone_timeout_uses_active_profile_and_bounds_invalid_values(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _clone_timeout_seconds
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
config = home / "config.yaml"
config.write_text("plugins:\n clone_timeout_seconds: 137\n", encoding="utf-8")
assert _clone_timeout_seconds() == 137
other = tmp_path / "other"
other.mkdir()
monkeypatch.setenv("HERMES_HOME", str(other))
assert _clone_timeout_seconds() == 300
other_config = other / "config.yaml"
other_config.write_text("plugins:\n clone_timeout_seconds: 0\n", encoding="utf-8")
assert _clone_timeout_seconds() == 300
other_config.write_text("plugins:\n clone_timeout_seconds: 7200\n", encoding="utf-8")
assert _clone_timeout_seconds() == 3600
def test_force_reinstall_does_not_drift_pin_without_explicit_new_ref(
monkeypatch, tmp_path
):

View File

@@ -161,6 +161,10 @@ plugins:
- disk-cleanup
disabled: # optional deny-list — always wins if a name appears in both
- noisy-plugin
# Optional: deadline (seconds) for each Git clone or pinned-commit fetch
# during plugin installation, including automatic memory-provider migration.
# Default 300; values above 3600 are clamped.
clone_timeout_seconds: 300
# Optional: wall-clock cap (seconds) for timeout-bounded in-process Python
# plugin hook callbacks (hot-path observers + pre_tool_call). Default 30;
# set 0 to disable; values above 600 are clamped. Timed-out pre_tool_call