diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 179a9e5374..58bf136aa7 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 # ============================================================================= diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 5ded10e044..1516fad8f9 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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, diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 760331751c..4d2dcca78b 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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) diff --git a/tests/hermes_cli/test_plugin_install_ref.py b/tests/hermes_cli/test_plugin_install_ref.py index 2241959c69..ee58b6520d 100644 --- a/tests/hermes_cli/test_plugin_install_ref.py +++ b/tests/hermes_cli/test_plugin_install_ref.py @@ -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 ): diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 1d6a1a49e4..12732262b0 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -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