main
py 829 lines 29.1 KB
Raw
1 import base64
2 from contextvars import ContextVar, Token
3 from copy import deepcopy
4 import hashlib
5 import json
6 import os
7 import re
8 import subprocess
9 from typing import Any, Literal, TypedDict, cast, TypeVar
10
11 import models
12 import pytz # type: ignore
13 from helpers import runtime, defer, git, subagents
14 from . import files, dotenv
15 from helpers.print_style import PrintStyle
16 from helpers.providers import get_providers, FieldOption as ProvidersFO
17 from helpers.secrets import get_default_secrets_manager
18 from helpers import dirty_json
19 from helpers.notification import NotificationManager, NotificationType, NotificationPriority
20
21
22 T = TypeVar('T')
23
24 def get_default_value(name: str, value: T) -> T:
25 """
26 Load setting value from .env with A0_SET_ prefix, falling back to default.
27
28 Args:
29 name: Setting name (will be prefixed with A0_SET_)
30 value: Default value to use if env var not set
31
32 Returns:
33 Environment variable value (type-normalized) or default value
34 """
35 env_value = dotenv.get_dotenv_value(f"A0_SET_{name}", dotenv.get_dotenv_value(f"A0_SET_{name.upper()}", None))
36
37 if env_value is None:
38 return value
39
40 # Normalize type to match value param type
41 try:
42 if isinstance(value, bool):
43 return env_value.strip().lower() in ('true', '1', 'yes', 'on') # type: ignore
44 elif isinstance(value, dict):
45 return json.loads(env_value.strip()) # type: ignore
46 elif isinstance(value, str):
47 return str(env_value).strip() # type: ignore
48 else:
49 return type(value)(env_value.strip()) # type: ignore
50 except (ValueError, TypeError, json.JSONDecodeError) as e:
51 PrintStyle(background_color="yellow", font_color="black").print(
52 f"Warning: Invalid value for A0_SET_{name}='{env_value}': {e}. Using default: {value}"
53 )
54 return value
55
56 class Settings(TypedDict):
57 version: str
58
59 agent_profile: str
60 agent_knowledge_subdir: str
61 max_consecutive_unusable_responses: int
62 timezone: str
63 time_format: str
64 ui_control_visibility: dict[str, dict[str, bool]]
65
66 workdir_path: str
67 workdir_show: bool
68 workdir_max_depth: int
69 workdir_max_files: int
70 workdir_max_folders: int
71 workdir_max_lines: int
72 workdir_gitignore: str
73 file_browser_remember_last_directory: bool
74
75 api_keys: dict[str, str]
76
77 auth_login: str
78 auth_password: str
79 root_password: str
80
81 rfc_auto_docker: bool
82 rfc_url: str
83 rfc_password: str
84 rfc_port_http: int
85
86 websocket_server_restart_enabled: bool
87 uvicorn_access_logs_enabled: bool
88
89 mcp_servers: str
90 mcp_client_init_timeout: int
91 mcp_client_tool_timeout: int
92 mcp_server_enabled: bool
93 mcp_server_token: str
94
95 a2a_server_enabled: bool
96
97 variables: str
98 secrets: str
99
100 # LiteLLM global kwargs applied to all model calls
101 litellm_global_kwargs: dict[str, Any]
102
103 update_check_enabled: bool
104 chat_inherit_project: bool
105
106
107 class PartialSettings(Settings, total=False):
108 pass
109
110
111 class FieldOption(TypedDict):
112 value: str
113 label: str
114
115 class SettingsField(TypedDict, total=False):
116 id: str
117 title: str
118 description: str
119 type: Literal[
120 "text",
121 "number",
122 "select",
123 "range",
124 "textarea",
125 "password",
126 "switch",
127 "button",
128 "html",
129 ]
130 value: Any
131 min: float
132 max: float
133 step: float
134 hidden: bool
135 options: list[FieldOption]
136 style: str
137
138
139 class SettingsSection(TypedDict, total=False):
140 id: str
141 title: str
142 description: str
143 fields: list[SettingsField]
144 tab: str # Indicates which tab this section belongs to
145
146 class ModelProvider(ProvidersFO):
147 pass
148
149 class SettingsOutputAdditional(TypedDict):
150 chat_providers: list[ModelProvider]
151 embedding_providers: list[ModelProvider]
152 agent_subdirs: list[FieldOption]
153 knowledge_subdirs: list[FieldOption]
154 timezones: list[FieldOption]
155 resolved_timezone: str
156 is_dockerized: bool
157 runtime_settings: dict[str, Any]
158
159
160 class SettingsOutput(TypedDict):
161 settings: Settings
162 additional: SettingsOutputAdditional
163
164
165 PASSWORD_PLACEHOLDER = "****PSWD****"
166 API_KEY_PLACEHOLDER = "************"
167 TIMEZONE_AUTO = "auto"
168 TIME_FORMAT_12H = "12h"
169 TIME_FORMAT_24H = "24h"
170 UI_CONTROL_VISIBILITY_DEFAULTS = {
171 "projectSelector": {"mobile": True, "desktop": True},
172 "time": {"mobile": False, "desktop": True},
173 "connectionStatus": {"mobile": True, "desktop": True},
174 "contextWindowUsage": {"mobile": True, "desktop": True},
175 "rightCanvasRail": {"mobile": True, "desktop": True},
176 }
177
178 SETTINGS_FILE = files.get_abs_path("usr/settings.json")
179 _settings: Settings | None = None
180 _runtime_settings_snapshot: Settings | None = None
181 _prompt_settings_snapshot: ContextVar[Settings | None] = ContextVar(
182 "prompt_settings_snapshot", default=None
183 )
184
185 OptionT = TypeVar("OptionT", bound=FieldOption)
186
187 def _ensure_option_present(options: list[OptionT] | None, current_value: str | None) -> list[OptionT]:
188 """
189 Ensure the currently selected value exists in a dropdown options list.
190 If missing, inserts it at the front as {value: current_value, label: current_value}.
191 """
192 opts = list(options or [])
193 if not current_value:
194 return opts
195 for o in opts:
196 if o.get("value") == current_value:
197 return opts
198 opts.insert(0, cast(OptionT, {"value": current_value, "label": current_value}))
199 return opts
200
201
202 def _is_valid_timezone(value: str) -> bool:
203 try:
204 pytz.timezone(value)
205 return True
206 except pytz.exceptions.UnknownTimeZoneError:
207 return False
208
209
210 def _normalize_timezone_setting(value: Any, default: str = TIMEZONE_AUTO) -> str:
211 timezone = str(value or "").strip()
212 if timezone.lower() == TIMEZONE_AUTO:
213 return TIMEZONE_AUTO
214 if _is_valid_timezone(timezone):
215 return timezone
216 return default if default == TIMEZONE_AUTO or _is_valid_timezone(default) else TIMEZONE_AUTO
217
218
219 def _normalize_time_format(value: Any, default: str = TIME_FORMAT_12H) -> str:
220 time_format = str(value or "").strip().lower()
221 if time_format in {TIME_FORMAT_12H, TIME_FORMAT_24H}:
222 return time_format
223 return default if default in {TIME_FORMAT_12H, TIME_FORMAT_24H} else TIME_FORMAT_12H
224
225
226 def _normalize_ui_control_visibility(value: Any) -> dict[str, dict[str, bool]]:
227 submitted = value if isinstance(value, dict) else {}
228 normalized = {}
229 for control, devices in UI_CONTROL_VISIBILITY_DEFAULTS.items():
230 submitted_devices = submitted.get(control, {})
231 if not isinstance(submitted_devices, dict):
232 submitted_devices = {}
233 normalized[control] = {
234 device: submitted_devices.get(device)
235 if isinstance(submitted_devices.get(device), bool)
236 else default
237 for device, default in devices.items()
238 }
239 return normalized
240
241
242 def _resolve_runtime_timezone(setting_value: str, browser_timezone: str | None = None) -> str:
243 if setting_value == TIMEZONE_AUTO:
244 candidate = str(browser_timezone or "").strip()
245 if _is_valid_timezone(candidate):
246 return candidate
247 try:
248 from helpers.localization import Localization
249
250 return Localization.get().get_timezone()
251 except Exception:
252 return "UTC"
253 return _normalize_timezone_setting(setting_value, default="UTC")
254
255
256 def _timezone_options() -> list[FieldOption]:
257 return [{"value": timezone, "label": timezone} for timezone in pytz.common_timezones]
258
259
260 def convert_out(settings: Settings) -> SettingsOutput:
261 out = SettingsOutput(
262 settings = settings.copy(),
263 additional = SettingsOutputAdditional(
264 chat_providers=get_providers("chat"),
265 embedding_providers=get_providers("embedding"),
266 is_dockerized=runtime.is_dockerized(),
267 agent_subdirs=[
268 {"value": key, "label": item.title or key}
269 for key, item in sorted(
270 subagents.get_available_agents_dict(None).items()
271 )
272 if key != "default"
273 ],
274 knowledge_subdirs=[{"value": subdir, "label": subdir}
275 for subdir in files.get_subdirectories("knowledge", exclude="default")],
276 timezones=_timezone_options(),
277 resolved_timezone="UTC",
278 runtime_settings={},
279 ),
280 )
281
282 # ensure dropdown options include currently selected values
283 additional = out["additional"]
284 current = out["settings"]
285
286 default_settings = get_default_settings()
287 runtime_settings = _runtime_settings_snapshot or settings
288 additional["runtime_settings"] = {
289 "uvicorn_access_logs_enabled": bool(
290 runtime_settings.get(
291 "uvicorn_access_logs_enabled",
292 default_settings["uvicorn_access_logs_enabled"],
293 )
294 ),
295 }
296
297 current_profile = current.get("agent_profile")
298 if current_profile and current_profile != "default" and not any(
299 option["value"] == current_profile
300 for option in additional["agent_subdirs"]
301 ):
302 additional["agent_subdirs"].append(
303 {"value": current_profile, "label": f"{current_profile} (unavailable)"}
304 )
305 additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
306 if current.get("timezone") != TIMEZONE_AUTO:
307 additional["timezones"] = _ensure_option_present(additional.get("timezones"), current.get("timezone"))
308 additional["resolved_timezone"] = _resolve_runtime_timezone(current.get("timezone", TIMEZONE_AUTO))
309
310 # masked api keys
311 providers = get_providers("chat") + get_providers("embedding")
312 for provider in providers:
313 provider_name = provider["value"]
314 api_key = settings["api_keys"].get(provider_name, models.get_api_key(provider_name))
315 settings["api_keys"][provider_name] = API_KEY_PLACEHOLDER if api_key and api_key != "None" else ""
316
317 # load auth from dotenv
318 out["settings"]["auth_login"] = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN) or ""
319 out["settings"]["auth_password"] = (
320 PASSWORD_PLACEHOLDER if dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD) else ""
321 )
322 out["settings"]["rfc_password"] = (
323 PASSWORD_PLACEHOLDER if dotenv.get_dotenv_value(dotenv.KEY_RFC_PASSWORD) else ""
324 )
325 out["settings"]["root_password"] = (
326 PASSWORD_PLACEHOLDER if dotenv.get_dotenv_value(dotenv.KEY_ROOT_PASSWORD) else ""
327 )
328
329 #secrets
330 secrets_manager = get_default_secrets_manager()
331 try:
332 out["settings"]["secrets"] = secrets_manager.get_masked_secrets()
333 except Exception:
334 out["settings"]["secrets"] = ""
335
336 # mask API keys before sending to frontend
337 if isinstance(out["settings"].get("api_keys"), dict):
338 for provider, value in list(out["settings"]["api_keys"].items()):
339 if value:
340 out["settings"]["api_keys"][provider] = API_KEY_PLACEHOLDER
341
342 # normalize certain fields
343 for key, value in list(out["settings"].items()):
344 # convert kwargs dicts to .env format
345 if (key.endswith("_kwargs")) and isinstance(value, dict):
346 out["settings"][key] = _dict_to_env(value)
347 return out
348
349 def _get_api_key_field(settings: Settings, provider: str, title: str) -> SettingsField:
350 key = settings["api_keys"].get(provider, models.get_api_key(provider))
351 # For API keys, use simple asterisk placeholder for existing keys
352 return {
353 "id": f"api_key_{provider}",
354 "title": title,
355 "type": "text",
356 "value": (API_KEY_PLACEHOLDER if key and key != "None" else ""),
357 }
358
359
360 def convert_in(settings: Settings) -> Settings:
361 current = get_settings()
362
363 for key, value in settings.items():
364 # Special handling for *_kwargs (stored as .env text)
365 if (key.endswith("_kwargs")) and isinstance(value, str):
366 current[key] = _env_to_dict(value)
367 continue
368
369 current[key] = value
370 return current
371
372
373 def get_settings() -> Settings:
374 global _settings
375 if not _settings:
376 _settings = _read_settings_file()
377 if not _settings:
378 _settings = get_default_settings()
379 norm = normalize_settings(_settings)
380 _load_sensitive_settings(norm)
381 return norm
382
383
384 def get_settings_for_prompt() -> Settings:
385 if (snapshot := _prompt_settings_snapshot.get()) is not None:
386 return deepcopy(snapshot)
387 return get_settings()
388
389
390 def begin_prompt_settings_snapshot() -> Token:
391 return _prompt_settings_snapshot.set(get_settings())
392
393
394 def end_prompt_settings_snapshot(token: Token) -> None:
395 _prompt_settings_snapshot.reset(token)
396
397
398 def reload_settings() -> Settings:
399 global _settings
400 _settings = None
401 current = get_settings()
402 if _prompt_settings_snapshot.get() is not None:
403 _prompt_settings_snapshot.set(deepcopy(current))
404 return current
405
406
407 def set_runtime_settings_snapshot(settings: Settings) -> None:
408 global _runtime_settings_snapshot
409 _runtime_settings_snapshot = settings.copy()
410
411
412 def set_settings(settings: Settings, apply: bool = True, browser_timezone: str | None = None):
413 global _settings
414 previous = _settings
415 _settings = normalize_settings(settings)
416 _write_settings_file(_settings)
417 if apply:
418 _apply_settings(previous, browser_timezone)
419 return reload_settings()
420
421
422 def set_settings_delta(delta: dict, apply: bool = True):
423 current = get_settings()
424 new = {**current, **delta}
425 return set_settings(new, apply) # type: ignore
426
427
428 def merge_settings(original: Settings, delta: dict) -> Settings:
429 merged = original.copy()
430 merged.update(delta)
431 return merged
432
433
434 def normalize_settings(settings: Settings) -> Settings:
435 copy = settings.copy()
436 default = get_default_settings()
437
438 # adjust settings values to match current version if needed
439 if "version" not in copy or copy["version"] != default["version"]:
440 _adjust_to_version(copy, default)
441 copy["version"] = default["version"] # sync version
442
443 # remove keys that are not in default
444 keys_to_remove = [key for key in copy if key not in default]
445 for key in keys_to_remove:
446 del copy[key]
447
448 # add missing keys and normalize types
449 for key, value in default.items():
450 if key not in copy:
451 copy[key] = value
452 else:
453 try:
454 copy[key] = type(value)(copy[key]) # type: ignore
455 if isinstance(copy[key], str):
456 copy[key] = copy[key].strip() # strip strings
457 except (ValueError, TypeError):
458 copy[key] = value # make default instead
459
460 if copy["agent_profile"] == "default":
461 copy["agent_profile"] = "agent0"
462
463 # mcp server token is set automatically
464 copy["mcp_server_token"] = create_auth_token()
465 copy["max_consecutive_unusable_responses"] = max(
466 1, copy["max_consecutive_unusable_responses"]
467 )
468 copy["timezone"] = _normalize_timezone_setting(copy.get("timezone"), default["timezone"])
469 copy["time_format"] = _normalize_time_format(copy.get("time_format"), default["time_format"])
470 copy["ui_control_visibility"] = _normalize_ui_control_visibility(copy.get("ui_control_visibility"))
471
472 return copy
473
474
475 def _adjust_to_version(settings: Settings, default: Settings):
476 # starting with 0.9, the default prompt subfolder for agent no. 0 is agent0
477 # switch to agent0 if the old default is used from v0.8
478 if "version" not in settings or settings["version"].startswith("v0.8"):
479 if "agent_profile" not in settings or settings["agent_profile"] == "default":
480 settings["agent_profile"] = "agent0"
481
482
483
484 def _load_sensitive_settings(settings: Settings):
485 # load api keys from .env
486 providers = get_providers("chat") + get_providers("embedding")
487 for provider in providers:
488 provider_name = provider["value"]
489 api_key = settings["api_keys"].get(provider_name) or models.get_api_key(provider_name)
490 if api_key and api_key != "None":
491 settings["api_keys"][provider_name] = api_key
492
493 # load auth fields from .env
494 settings["auth_login"] = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN) or ""
495 settings["auth_password"] = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD) or ""
496 settings["rfc_password"] = dotenv.get_dotenv_value(dotenv.KEY_RFC_PASSWORD) or ""
497 settings["root_password"] = dotenv.get_dotenv_value(dotenv.KEY_ROOT_PASSWORD) or ""
498
499 # load secrets raw content
500 secrets_manager = get_default_secrets_manager()
501 try:
502 settings["secrets"] = secrets_manager.read_secrets_raw()
503 except Exception:
504 settings["secrets"] = ""
505
506
507 def _read_settings_file() -> Settings | None:
508 if os.path.exists(SETTINGS_FILE):
509 content = files.read_file(SETTINGS_FILE)
510 parsed = json.loads(content)
511 return normalize_settings(parsed)
512
513
514 def _write_settings_file(settings: Settings):
515 settings = settings.copy()
516 _write_sensitive_settings(settings)
517 _remove_sensitive_settings(settings)
518
519 # write settings
520 content = json.dumps(settings, indent=4)
521 files.write_file(SETTINGS_FILE, content)
522
523
524 def _remove_sensitive_settings(settings: Settings):
525 settings["api_keys"] = {}
526 settings["auth_login"] = ""
527 settings["auth_password"] = ""
528 settings["rfc_password"] = ""
529 settings["root_password"] = ""
530 settings["mcp_server_token"] = ""
531 settings["secrets"] = ""
532
533
534 def _write_sensitive_settings(settings: Settings):
535 for key, val in settings["api_keys"].items():
536 if val != API_KEY_PLACEHOLDER:
537 dotenv.save_dotenv_value(f"API_KEY_{key.upper()}", val)
538
539 dotenv.save_dotenv_value(dotenv.KEY_AUTH_LOGIN, settings["auth_login"])
540 if settings["auth_password"] != PASSWORD_PLACEHOLDER:
541 dotenv.save_dotenv_value(dotenv.KEY_AUTH_PASSWORD, settings["auth_password"])
542 if settings["rfc_password"] != PASSWORD_PLACEHOLDER:
543 dotenv.save_dotenv_value(dotenv.KEY_RFC_PASSWORD, settings["rfc_password"])
544 if settings["root_password"] != PASSWORD_PLACEHOLDER:
545 if runtime.is_dockerized():
546 dotenv.save_dotenv_value(dotenv.KEY_ROOT_PASSWORD, settings["root_password"])
547 set_root_password(settings["root_password"])
548
549 # Handle secrets separately - merge with existing preserving comments/order and support deletions
550 secrets_manager = get_default_secrets_manager()
551 submitted_content = settings["secrets"]
552 secrets_manager.save_secrets_with_merge(submitted_content)
553
554
555
556 def get_default_settings() -> Settings:
557 gitignore = files.read_file(files.get_abs_path("conf/workdir.gitignore"))
558 return Settings(
559 version=_get_version(),
560 api_keys={},
561 auth_login="",
562 auth_password="",
563 root_password="",
564 agent_profile=get_default_value("agent_profile", "agent0"),
565 agent_knowledge_subdir=get_default_value("agent_knowledge_subdir", "custom"),
566 max_consecutive_unusable_responses=get_default_value(
567 "max_consecutive_unusable_responses", 5
568 ),
569 timezone=_normalize_timezone_setting(get_default_value("timezone", TIMEZONE_AUTO)),
570 time_format=_normalize_time_format(get_default_value("time_format", TIME_FORMAT_12H)),
571 ui_control_visibility=_normalize_ui_control_visibility(
572 get_default_value("ui_control_visibility", UI_CONTROL_VISIBILITY_DEFAULTS)
573 ),
574 workdir_path=get_default_value("workdir_path", files.get_abs_path_dockerized("usr/workdir")),
575 workdir_show=get_default_value("workdir_show", True),
576 workdir_max_depth=get_default_value("workdir_max_depth", 5),
577 workdir_max_files=get_default_value("workdir_max_files", 20),
578 workdir_max_folders=get_default_value("workdir_max_folders", 20),
579 workdir_max_lines=get_default_value("workdir_max_lines", 250),
580 workdir_gitignore=get_default_value("workdir_gitignore", gitignore),
581 file_browser_remember_last_directory=get_default_value(
582 "file_browser_remember_last_directory",
583 True,
584 ),
585 rfc_auto_docker=get_default_value("rfc_auto_docker", True),
586 rfc_url=get_default_value("rfc_url", "localhost"),
587 rfc_password="",
588 rfc_port_http=get_default_value("rfc_port_http", 55080),
589 websocket_server_restart_enabled=get_default_value("websocket_server_restart_enabled", True),
590 uvicorn_access_logs_enabled=get_default_value("uvicorn_access_logs_enabled", False),
591 mcp_servers=get_default_value("mcp_servers", '{\n "mcpServers": {}\n}'),
592 mcp_client_init_timeout=get_default_value("mcp_client_init_timeout", 10),
593 mcp_client_tool_timeout=get_default_value("mcp_client_tool_timeout", 120),
594 mcp_server_enabled=get_default_value("mcp_server_enabled", False),
595 mcp_server_token=create_auth_token(),
596 a2a_server_enabled=get_default_value("a2a_server_enabled", False),
597 variables="",
598 secrets="",
599 litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}),
600 update_check_enabled=get_default_value("update_check_enabled", True),
601 chat_inherit_project=get_default_value("chat_inherit_project", True),
602 )
603
604
605 def _apply_timezone_setting(previous: Settings | None, browser_timezone: str | None = None) -> None:
606 if not _settings:
607 return
608
609 from helpers.localization import Localization
610
611 localization = Localization.get()
612 previous_timezone = localization.get_timezone()
613 target_timezone = _resolve_runtime_timezone(_settings["timezone"], browser_timezone)
614 if (
615 previous
616 and _settings["timezone"] == previous.get("timezone")
617 and _settings["timezone"] != TIMEZONE_AUTO
618 and previous_timezone == target_timezone
619 ):
620 return
621
622 localization.set_timezone(target_timezone)
623 current_timezone = localization.get_timezone()
624 if current_timezone == previous_timezone:
625 return
626
627 try:
628 from helpers import plugins
629
630 plugins.call_plugin_hook(
631 "_office",
632 "timezone_changed",
633 None,
634 previous_timezone=previous_timezone,
635 timezone=current_timezone,
636 )
637 except Exception:
638 return
639
640
641 def _apply_settings(previous: Settings | None, browser_timezone: str | None = None):
642 global _settings
643 if _settings:
644 _apply_timezone_setting(previous, browser_timezone)
645
646 from agent import Agent, AgentContext
647 from initialize import initialize_agent
648
649 for ctx in AgentContext.all():
650 profile = str(
651 getattr(ctx.config, "profile", "") or _settings["agent_profile"]
652 )
653 ctx.config = initialize_agent(override_settings={"agent_profile": profile})
654 agent = ctx.agent0
655 while agent:
656 agent_profile = str(
657 getattr(getattr(agent, "config", None), "profile", "") or profile
658 )
659 agent.config = (
660 ctx.config
661 if agent is ctx.agent0 and agent_profile == profile
662 else initialize_agent(
663 override_settings={"agent_profile": agent_profile}
664 )
665 )
666 agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE)
667
668 # update mcp settings if necessary
669 if not previous or _settings["mcp_servers"] != previous["mcp_servers"]:
670 from helpers.mcp_handler import MCPConfig
671
672 async def update_mcp_settings(mcp_servers: str):
673 PrintStyle(
674 background_color="black", font_color="white", padding=True
675 ).print("Updating MCP config...")
676 NotificationManager.send_notification(
677 type=NotificationType.INFO,
678 priority=NotificationPriority.NORMAL,
679 message="Updating MCP settings...",
680 display_time=999,
681 group="settings-mcp"
682 )
683
684 mcp_config = MCPConfig.get_instance()
685 try:
686 MCPConfig.update(mcp_servers)
687 except Exception as e:
688
689 NotificationManager.send_notification(
690 type=NotificationType.ERROR,
691 priority=NotificationPriority.HIGH,
692 message="Failed to update MCP settings",
693 detail=str(e),
694 )
695 (
696 PrintStyle(
697 background_color="red", font_color="black", padding=True
698 ).print("Failed to update MCP settings")
699 )
700 (
701 PrintStyle(
702 background_color="black", font_color="red", padding=True
703 ).print(f"{e}")
704 )
705
706 PrintStyle(
707 background_color="#6734C3", font_color="white", padding=True
708 ).print("Parsed MCP config:")
709 (
710 PrintStyle(
711 background_color="#334455", font_color="white", padding=False
712 ).print(mcp_config.model_dump_json())
713 )
714 NotificationManager.send_notification(
715 type=NotificationType.INFO,
716 priority=NotificationPriority.NORMAL,
717 message="Finished updating MCP settings.",
718 group="settings-mcp"
719 )
720
721 task2 = defer.DeferredTask().start_task(
722 update_mcp_settings, _settings["mcp_servers"]
723 ) # TODO overkill, replace with background task
724
725 # update token in mcp server
726 current_token = (
727 create_auth_token()
728 ) # TODO - ugly, token in settings is generated from dotenv and does not always correspond
729 if not previous or current_token != previous["mcp_server_token"]:
730
731 async def update_mcp_token(token: str):
732 from helpers.mcp_server import DynamicMcpProxy
733
734 DynamicMcpProxy.get_instance().reconfigure(token=token)
735
736 task3 = defer.DeferredTask().start_task(
737 update_mcp_token, current_token
738 ) # TODO overkill, replace with background task
739
740 # update token in a2a server
741 if not previous or current_token != previous["mcp_server_token"]:
742
743 async def update_a2a_token(token: str):
744 from helpers.fasta2a_server import DynamicA2AProxy
745
746 DynamicA2AProxy.get_instance().reconfigure(token=token)
747
748 task4 = defer.DeferredTask().start_task(
749 update_a2a_token, current_token
750 ) # TODO overkill, replace with background task
751
752
753 def _env_to_dict(data: str):
754 result = {}
755 for line in data.splitlines():
756 line = line.strip()
757 if not line or line.startswith('#'):
758 continue
759
760 if '=' not in line:
761 continue
762
763 key, value = line.split('=', 1)
764 key = key.strip()
765 value = value.strip()
766
767 # If quoted, treat as string
768 if value.startswith('"') and value.endswith('"'):
769 result[key] = value[1:-1].replace('\\"', '"') # Unescape quotes
770 elif value.startswith("'") and value.endswith("'"):
771 result[key] = value[1:-1].replace("\\'", "'") # Unescape quotes
772 else:
773 # Not quoted, try JSON parse
774 try:
775 result[key] = json.loads(value)
776 except (json.JSONDecodeError, ValueError):
777 result[key] = value
778
779 return result
780
781
782 def _dict_to_env(data_dict):
783 lines = []
784 for key, value in data_dict.items():
785 if isinstance(value, str):
786 # Quote strings and escape internal quotes
787 escaped_value = value.replace('"', '\\"')
788 lines.append(f'{key}="{escaped_value}"')
789 elif isinstance(value, (dict, list, bool)) or value is None:
790 # Serialize as unquoted JSON
791 lines.append(f'{key}={json.dumps(value, separators=(",", ":"))}')
792 else:
793 # Numbers and other types as unquoted strings
794 lines.append(f'{key}={value}')
795
796 return "\n".join(lines)
797
798
799 def set_root_password(password: str):
800 if not runtime.is_dockerized():
801 raise Exception("root password can only be set in dockerized environments")
802 _result = subprocess.run(
803 ["chpasswd"],
804 input=f"root:{password}".encode(),
805 capture_output=True,
806 check=True,
807 )
808 dotenv.save_dotenv_value(dotenv.KEY_ROOT_PASSWORD, password)
809
810
811 def get_runtime_config(set: Settings):
812 # SSH config is now managed by the code_execution plugin.
813 # This function is kept for backward compatibility but returns an empty dict.
814 return {}
815
816
817 def create_auth_token() -> str:
818 runtime_id = runtime.get_persistent_id()
819 username = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN) or ""
820 password = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD) or ""
821 # use base64 encoding for a more compact token with alphanumeric chars
822 hash_bytes = hashlib.sha256(f"{runtime_id}:{username}:{password}".encode()).digest()
823 # encode as base64 and remove any non-alphanumeric chars (like +, /, =)
824 b64_token = base64.urlsafe_b64encode(hash_bytes).decode().replace("=", "")
825 return b64_token[:16]
826
827
828 def _get_version():
829 return git.get_version()