From 7bef5a6ee35214bd1bee74429827baae83ae10f2 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 7 Dec 2025 20:52:26 +0700 Subject: [PATCH] Changed dashboard listen address to 127.0.0.1 Also minor refactoring in SerenaConfig that allows not repeating the defaults (as field defaults and in `from_config_file`) --- src/serena/agent.py | 8 +++++-- src/serena/config/serena_config.py | 35 ++++++++++++++++++------------ src/serena/dashboard.py | 6 ++--- src/serena/util/general.py | 22 ++++++++++++++++++- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index c02e1051..2d728610 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -271,8 +271,12 @@ class SerenaAgent: if self.serena_config.web_dashboard: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats - ).run_in_thread() - dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html" + ).run_in_thread(host=self.serena_config.web_dashboard_host) + host_for_url = self.serena_config.web_dashboard_host + # this usually means we are in docker, so we use localhost for URL (the user has to port-forward then) + if host_for_url == "0.0.0.0": + host_for_url = "127.0.0.1" + dashboard_url = f"http://{self.serena_config.web_dashboard_host}:{port}/dashboard/index.html" log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: # open the dashboard URL in the default web browser (using a separate process to control diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index c5345d48..cfe3f0b1 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -28,7 +28,7 @@ from serena.constants import ( SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME, ) -from serena.util.general import load_yaml, save_yaml +from serena.util.general import get_dataclass_default, load_yaml, save_yaml from serena.util.inspection import determine_programming_language_composition from solidlsp.ls_config import Language @@ -342,6 +342,8 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): trace_lsp_communication: bool = False web_dashboard: bool = True web_dashboard_open_on_launch: bool = True + web_dashboard_host: str = "127.0.0.1" + """Will be automatically set to '0.0.0.0' when running in Docker. Not loaded from config file.""" tool_timeout: float = DEFAULT_TOOL_TIMEOUT loaded_commented_yaml: CommentedMap | None = None config_file_path: str | None = None @@ -373,6 +375,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): CONFIG_FILE = "serena_config.yml" CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization + def __post_init__(self) -> None: + if is_running_in_docker(): + self.web_dashboard_host = "0.0.0.0" + def _tostring_includes(self) -> list[str]: return ["config_file_path"] @@ -456,23 +462,24 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.projects.append(project) # set other configuration parameters + def get_value_or_default(field_name: str) -> Any: + return loaded_commented_yaml.get(field_name, get_dataclass_default(SerenaConfig, field_name)) + if is_running_in_docker(): instance.gui_log_window_enabled = False # not supported in Docker else: - instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) + instance.gui_log_window_enabled = get_value_or_default("gui_log_window_enabled") instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) - instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True) - instance.web_dashboard_open_on_launch = loaded_commented_yaml.get("web_dashboard_open_on_launch", True) - instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT) - instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False) - instance.excluded_tools = loaded_commented_yaml.get("excluded_tools", []) - instance.included_optional_tools = loaded_commented_yaml.get("included_optional_tools", []) - instance.jetbrains = loaded_commented_yaml.get("jetbrains", False) - instance.token_count_estimator = loaded_commented_yaml.get( - "token_count_estimator", RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name - ) - instance.default_max_tool_answer_chars = loaded_commented_yaml.get("default_max_tool_answer_chars", 150_000) - instance.ls_specific_settings = loaded_commented_yaml.get("ls_specific_settings", {}) + instance.web_dashboard = get_value_or_default("web_dashboard") + instance.web_dashboard_open_on_launch = get_value_or_default("web_dashboard_open_on_launch") + instance.tool_timeout = get_value_or_default("tool_timeout") + instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication") + instance.excluded_tools = get_value_or_default("excluded_tools") + instance.included_optional_tools = get_value_or_default("included_optional_tools") + instance.jetbrains = get_value_or_default("jetbrains") + instance.token_count_estimator = get_value_or_default("token_count_estimator") + instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars") + instance.ls_specific_settings = get_value_or_default("ls_specific_settings") # re-save the configuration file if any migrations were performed if num_project_migrations > 0: diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 6005bcad..5b414e2d 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -560,7 +560,7 @@ class SerenaDashboardAPI: raise RuntimeError(f"No free ports found starting from {start_port}") - def run(self, host: str = "0.0.0.0", port: int = 0x5EDA) -> int: + def run(self, host: str, port: int) -> int: """ Runs the dashboard on the given host and port and returns the port number. """ @@ -572,8 +572,8 @@ class SerenaDashboardAPI: self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port - def run_in_thread(self) -> tuple[threading.Thread, int]: + def run_in_thread(self, host: str) -> tuple[threading.Thread, int]: port = self._find_first_free_port(0x5EDA) - thread = threading.Thread(target=lambda: self.run(port=port), daemon=True) + thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) thread.start() return thread, port diff --git a/src/serena/util/general.py b/src/serena/util/general.py index 4a1397e2..f380c699 100644 --- a/src/serena/util/general.py +++ b/src/serena/util/general.py @@ -1,5 +1,6 @@ import os -from typing import Literal, overload +from dataclasses import MISSING, Field +from typing import Any, Literal, cast, overload from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap @@ -32,3 +33,22 @@ def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = Fa os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding=SERENA_FILE_ENCODING) as f: yaml.dump(data, f) + + +def get_dataclass_default(cls: type, field_name: str) -> Any: + """ + Gets the default value of a dataclass field. + + :param cls: The dataclass type. + :param field_name: The name of the field. + :return: The default value of the field (either from default or default_factory). + """ + field = cast(Field, cls.__dataclass_fields__[field_name]) # type: ignore[attr-defined] + + if field.default is not MISSING: + return field.default + + if field.default_factory is not MISSING: # default_factory is a function + return field.default_factory() + + raise AttributeError(f"{field_name} has no default")