diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a26a59a..3fcd8b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. Current effects: - - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `activation_command` (and `activation_command_timeout`) defined in project configurations will only + be executed for trusted projects: an optional shell command run in the project root before the + language backend initialises (e.g. to generate source files a language server needs to index). + Exit code is the primary completion signal; `activation_command_timeout` (default 180s) is a safety + backstop — on expiry the process is killed and activation continues. Failures and timeouts are + logged but do not abort activation. - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 8fa10eca..fa710498 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -53,6 +53,7 @@ The file allows you to configure ... * write access * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos * an initial prompt that shall be passed to the LLM whenever the project is activated + * a shell command to run upon project activation (prior to language backend initialisation) * the set of tools and modes to use for the project * and some other settings. diff --git a/src/serena/agent.py b/src/serena/agent.py index 953e4241..637e8ae1 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -63,6 +63,7 @@ from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.util import subprocess_util +from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer @@ -1200,6 +1201,7 @@ class SerenaAgent: self._update_active_tools() def init_project_services() -> None: + self._run_project_activation_command(project) self._init_active_project_language_backend() # initialise the project's language backend in the background @@ -1214,6 +1216,48 @@ class SerenaAgent: return True + @staticmethod + def _run_project_activation_command(project: Project) -> None: + """ + Runs the given project's activation_command (if set and the project is trusted). + Failures are logged. + """ + activation_command = project.project_config.activation_command + if not activation_command: + return + if not project.is_trusted(): + log.warning( + f"Project path {project.project_root} is not trusted, ignoring activation_command " + "from project configuration. To trust the project, modify the trusted path patterns " + "in the global configuration." + ) + return + timeout = project.project_config.activation_command_timeout + cmd = subprocess_util.convert_shell_cmd(activation_command) + log.info(f"Running activation_command for project '{project.project_name}': {cmd}") + try: + with LogTime("Project activation command", logger=log): + p = subprocess.Popen( + cmd, + shell=True, + cwd=project.project_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **subprocess_util.subprocess_kwargs(), + ) + try: + _, stderr = p.communicate(timeout=timeout) + if p.returncode != 0: + log.error(f"activation_command for project '{project.project_name}' failed (exit {p.returncode}): {stderr.strip()}") + except subprocess.TimeoutExpired: + log.error( + f"Activation_command for project '{project.project_name}' timed out after " + f"{timeout}s; terminating process and continuing with backend initialisation." + ) + terminate_process_tree_with_kill_fallback(p, terminate_timeout=5.0, process_name="activation_command") + except Exception: + log.exception(f"Unexpected error running activation_command for project '{project.project_name}'") + def _init_active_project_language_backend(self) -> None: """ Initialises the active project's language backend diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 2b580082..390eb2b2 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -308,6 +308,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + activation_command: str | None = None + activation_command_timeout: float = 180.0 # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) @@ -493,6 +495,15 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e + # Validate activation_command_timeout + activation_command_timeout_raw = data.get("activation_command_timeout", 180.0) + try: + activation_command_timeout = float(activation_command_timeout_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"activation_command_timeout must be a number, got: {activation_command_timeout_raw}") from e + if activation_command_timeout <= 0: + raise ValueError(f"activation_command_timeout must be positive, got: {activation_command_timeout}") + # Validate symbol_info_budget symbol_info_budget_raw = data["symbol_info_budget"] symbol_info_budget = symbol_info_budget_raw @@ -540,6 +551,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, ls_specific_settings=data.get("ls_specific_settings", {}), + activation_command=data.get("activation_command"), + activation_command_timeout=activation_command_timeout, _local_override_keys=local_override_keys, ) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a4dc26ee..5d0b7441 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -37,6 +37,19 @@ languages: ["python"] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180 + # line ending convention to use when writing source files. # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 0a3cb43c..e66ad32e 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -607,3 +607,51 @@ class TestMemoriesManagerCustomPath: manager.save_memory("topic_b", "content b", is_tool_context=False) memories = manager.list_project_memories() assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"] + + +class TestProjectConfigActivationCommand: + """Tests for the activation_command and activation_command_timeout fields.""" + + def _base_data(self) -> dict: + data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + data["project_name"] = "test" + data["languages"] = ["python"] + return data + + def test_activation_command_defaults_to_none(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command is None + + def test_activation_command_timeout_default(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command_timeout == 180.0 + + def test_activation_command_parsed_from_dict(self): + data = self._base_data() + data["activation_command"] = "npx nx run-many -t build" + data["activation_command_timeout"] = 300 + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command == "npx nx run-many -t build" + assert config.activation_command_timeout == 300.0 + + def test_activation_command_defaults_when_absent(self): + data = self._base_data() + data.pop("activation_command", None) + data.pop("activation_command_timeout", None) + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command is None + assert config.activation_command_timeout == 180.0 + + def test_activation_command_timeout_zero_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = 0 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) + + def test_activation_command_timeout_negative_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = -10 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) diff --git a/test/serena/test_activation_command.py b/test/serena/test_activation_command.py new file mode 100644 index 00000000..39bca274 --- /dev/null +++ b/test/serena/test_activation_command.py @@ -0,0 +1,100 @@ +"""Tests for SerenaAgent._run_activation_command.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _make_project( + project_root: Path, + activation_command: str | None = None, + activation_command_timeout: float = 180.0, + trusted: bool = True, +) -> Project: + """Create a minimal Project pointing at project_root with the given activation settings.""" + serena_config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + trusted_project_path_patterns=["**"] if trusted else [], + ) + project_config = ProjectConfig( + project_name="test-activation", + languages=[Language.PYTHON], + activation_command=activation_command, + activation_command_timeout=activation_command_timeout, + ) + return Project( + project_root=str(project_root), + project_config=project_config, + serena_config=serena_config, + ) + + +class TestRunActivationCommand: + """Unit tests for SerenaAgent._run_activation_command.""" + + def _make_agent(self, project: Project): + from serena.agent import SerenaAgent + + agent = SerenaAgent.__new__(SerenaAgent) + # wire up only what _run_activation_command needs + agent.serena_config = project.serena_config + return agent + + def test_no_command_does_nothing(self, tmp_path: Path, caplog): + project = _make_project(tmp_path, activation_command=None) + agent = self._make_agent(project) + with caplog.at_level(logging.INFO): + agent._run_project_activation_command(project) + assert "activation_command" not in caplog.text + + def test_trusted_command_runs_in_project_root(self, tmp_path: Path): + sentinel = tmp_path / "sentinel.txt" + # write a sentinel file from within the command to confirm cwd is project_root + if sys.platform == "win32": + cmd = "type nul > sentinel.txt" + else: + cmd = "touch sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + agent._run_project_activation_command(project) + assert sentinel.exists(), "Command did not run in project root (sentinel file missing)" + + def test_untrusted_project_skips_command(self, tmp_path: Path, caplog): + sentinel = tmp_path / "sentinel.txt" + cmd = "touch sentinel.txt" if sys.platform != "win32" else "type nul > sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=False) + agent = self._make_agent(project) + with caplog.at_level(logging.WARNING): + agent._run_project_activation_command(project) + assert not sentinel.exists(), "Command must not run for untrusted project" + assert "not trusted" in caplog.text + + def test_failing_command_logs_error_and_continues(self, tmp_path: Path, caplog): + cmd = "exit 1" if sys.platform != "win32" else "cmd /c exit 1" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not raise + assert any("failed" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) + + @pytest.mark.slow + def test_timeout_kills_process_and_continues(self, tmp_path: Path, caplog): + """A hanging command must be killed and activation must continue.""" + cmd = 'python3 -c "import time; time.sleep(60)"' + project = _make_project( + tmp_path, + activation_command=cmd, + activation_command_timeout=1.0, # 1s — fast for the test + trusted=True, + ) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not hang or raise + assert any("timed out" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR)