mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 22:14:54 +00:00
Add activation_command to project config (#1623)
Add activation_command to project config Adds two optional fields to ProjectConfig: - activation_command: shell command run in the project root before the language backend (LSP or JetBrains) initialises - activation_command_timeout: safety backstop (default 180s); on expiry the process tree is killed and activation continues The command is only executed for trusted projects (trusted_project_path_patterns). Exit code is the primary completion signal; failures and timeouts are logged but never abort activation (no return channel to the LLM at that point). Closes #1606 --------- Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
1 parent
684f457f0d
commit
dcbd8ce064
7 files changed
+226
-1
No files matched your search
+7
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=[])
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user