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:
Jordan CarneyandDominik Jain authored and GitHub committed 2026-07-01 18:12:05 +02:00
1 parent 684f457f0d
commit dcbd8ce064
7 files changed
+226 -1

No files matched your search

+7 -1
View File
@@ -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)
+1
View File
@@ -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.
+44
View File
@@ -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
+13
View File
@@ -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,
)
+13
View File
@@ -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.
+48
View File
@@ -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=[])
+100
View File
@@ -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)