fix(cli): make plugin clone timeout configurable
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user