From 9080c9a84c2acea54b59a902d729fab59600c7ef Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:44 +0600 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Union=20import=20error?= =?UTF-8?q?=20in=20FileBrowser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/file_browser.py | 66 ++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/helpers/file_browser.py b/helpers/file_browser.py index cdd2a282..43f9d7a2 100644 --- a/helpers/file_browser.py +++ b/helpers/file_browser.py @@ -3,7 +3,7 @@ import shutil import base64 import subprocess -from typing import Dict, List, Tuple, Any +from typing import Dict, List, Tuple, Any, Union from helpers.security import safe_filename from datetime import datetime @@ -13,9 +13,9 @@ class FileBrowser: ALLOWED_EXTENSIONS = { - 'image': {'jpg', 'jpeg', 'png', 'bmp'}, - 'code': {'py', 'js', 'sh', 'html', 'css'}, - 'document': {'md', 'pdf', 'txt', 'csv', 'json'} + "image": {"jpg", "jpeg", "png", "bmp"}, + "code": {"py", "js", "sh", "html", "css"}, + "document": {"md", "pdf", "txt", "csv", "json"}, } MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB @@ -23,6 +23,7 @@ class FileBrowser: def __init__(self): from helpers import runtime, files + if runtime.is_dockerized(): base_dir = "/ctx0" else: @@ -41,7 +42,7 @@ def _validate_path(self, path: Union[str, Path]) -> Path: full_path = path.resolve() if not str(full_path).startswith(str(self.base_dir)): - raise ValueError(f"Access denied: path {full_path} is outside base directory {self.base_dir}") + raise ValueError(f"Access denied: path {full_path} is outside base directory {self.base_dir}") return full_path def _check_file_size(self, file) -> bool: @@ -194,7 +195,7 @@ def _is_allowed_file(self, filename: str, file) -> bool: return True # Allow the file if it passes the checks def _get_file_extension(self, filename: str) -> str: - return filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + return filename.rsplit(".", 1)[1].lower() if "." in filename else "" def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Get files and folders using ls command for better error handling""" @@ -203,26 +204,21 @@ def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List try: # Use ls command to get directory listing - result = subprocess.run( - ['ls', '-la', str(full_path)], - capture_output=True, - text=True, - timeout=30 - ) + result = subprocess.run(["ls", "-la", str(full_path)], capture_output=True, text=True, timeout=30) if result.returncode != 0: PrintStyle.error(f"ls command failed: {result.stderr}") return files_list, folders # Parse ls output (skip first line which is "total X") - lines = result.stdout.strip().split('\n') + lines = result.stdout.strip().split("\n") if len(lines) <= 1: return files_list, folders for line in lines[1:]: # Skip the "total" line try: # Skip current and parent directory entries - if line.endswith(' .') or line.endswith(' ..'): + if line.endswith(" .") or line.endswith(" .."): continue # Parse ls -la output format @@ -232,19 +228,19 @@ def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List # Check if this is a symlink (permissions start with 'l') permissions = parts[0] - is_symlink = permissions.startswith('l') + is_symlink = permissions.startswith("l") if is_symlink: # For symlinks, extract the name before the '->' arrow - full_name_part = ' '.join(parts[8:]) - if ' -> ' in full_name_part: - filename = full_name_part.split(' -> ')[0] - symlink_target = full_name_part.split(' -> ')[1] + full_name_part = " ".join(parts[8:]) + if " -> " in full_name_part: + filename = full_name_part.split(" -> ")[0] + symlink_target = full_name_part.split(" -> ")[1] else: filename = full_name_part symlink_target = None else: - filename = ' '.join(parts[8:]) # Handle filenames with spaces + filename = " ".join(parts[8:]) # Handle filenames with spaces symlink_target = None if not filename: @@ -259,7 +255,7 @@ def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List entry_data: Dict[str, Any] = { "name": filename, "path": str(entry_path.relative_to(self.base_dir)), - "modified": datetime.fromtimestamp(stat_info.st_mtime).isoformat() + "modified": datetime.fromtimestamp(stat_info.st_mtime).isoformat(), } # Add symlink information if this is a symlink @@ -268,18 +264,18 @@ def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List entry_data["is_symlink"] = True if entry_path.is_file(): - entry_data.update({ - "type": self._get_file_type(filename), - "size": stat_info.st_size, - "is_dir": False - }) + entry_data.update( + {"type": self._get_file_type(filename), "size": stat_info.st_size, "is_dir": False} + ) files_list.append(entry_data) elif entry_path.is_dir(): - entry_data.update({ - "type": "folder", - "size": 0, # Directories show as 0 bytes - "is_dir": True - }) + entry_data.update( + { + "type": "folder", + "size": 0, # Directories show as 0 bytes + "is_dir": True, + } + ) folders.append(entry_data) except (OSError, PermissionError, FileNotFoundError) as e: @@ -327,11 +323,7 @@ def get_files(self, current_path: str = "") -> Dict: except Exception: parent_path = "" - return { - "entries": all_entries, - "current_path": current_path, - "parent_path": parent_path - } + return {"entries": all_entries, "current_path": current_path, "parent_path": parent_path} except Exception as e: PrintStyle.error(f"Error reading directory: {e}") @@ -349,4 +341,4 @@ def _get_file_type(self, filename: str) -> str: for file_type, extensions in self.ALLOWED_EXTENSIONS.items(): if ext in extensions: return file_type - return 'unknown' + return "unknown" From 8a48f24cba5a2071fa0f1b5463ab5a4ccf5b01e7 Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:46 +0600 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20Improve=20plugin=20loading=20ro?= =?UTF-8?q?bustness=20and=20code=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/plugins.py | 120 ++++++++++++++------------------------------- 1 file changed, 36 insertions(+), 84 deletions(-) diff --git a/helpers/plugins.py b/helpers/plugins.py index da050f8e..eb8d44b1 100644 --- a/helpers/plugins.py +++ b/helpers/plugins.py @@ -130,7 +130,7 @@ def on_plugin_change(events: list[WatchItem]): if plugin_name and plugin_name not in plugin_names: plugin_names.append(plugin_name) print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names) - python_change = any(path.endswith('.py') for path, _event in events) + python_change = any(path.endswith(".py") for path, _event in events) after_plugin_change(plugin_names or None, python_change=python_change) relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT] @@ -177,7 +177,7 @@ def expand_patterns(base_path: str): @extension.extensible -def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False): +def after_plugin_change(plugin_names: list[str] | None = None, python_change: bool = False): clear_plugin_cache(plugin_names) if python_change: refresh_plugin_modules(plugin_names) @@ -227,6 +227,8 @@ def get_plugins_list(): result: list[str] = [] seen_names: set[str] = set() for root in get_plugin_roots(): + if not Path(root).exists(): + continue for dir in Path(root).iterdir(): if not dir.is_dir() or dir.name.startswith("."): continue @@ -249,6 +251,8 @@ def get_enhanced_plugins_list( allowed_names = set(plugin_names) if plugin_names else None def load_plugins(root_path: str, is_custom: bool): + if not Path(root_path).exists(): + return for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name): try: if not d.is_dir() or d.name.startswith("."): @@ -323,9 +327,7 @@ def load_plugins(root_path: str, is_custom: bool): def get_custom_plugins_updates( plugin_names: list[str] | None = None, ) -> List[PluginUpdateInfo]: - plugins = get_enhanced_plugins_list( - custom=True, builtin=False, plugin_names=plugin_names - ) + plugins = get_enhanced_plugins_list(custom=True, builtin=False, plugin_names=plugin_names) results: list[PluginUpdateInfo] = [] for plugin in plugins: @@ -352,9 +354,7 @@ def get_plugin_meta(plugin_name: str): plugin_dir = find_plugin_dir(plugin_name) if not plugin_dir: return None - return PluginMetadata.model_validate( - files.read_file_yaml(files.get_abs_path(plugin_dir, META_FILE_NAME)) - ) + return PluginMetadata.model_validate(files.read_file_yaml(files.get_abs_path(plugin_dir, META_FILE_NAME))) def find_plugin_dir(plugin_name: str): @@ -362,16 +362,12 @@ def find_plugin_dir(plugin_name: str): return None # check if the plugin is in the user directory - user_plugin_path = files.get_abs_path( - files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME - ) + user_plugin_path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME) if files.exists(user_plugin_path): return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name) # check if the plugin is in the default directory - default_plugin_path = files.get_abs_path( - files.PLUGINS_DIR, plugin_name, META_FILE_NAME - ) + default_plugin_path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, META_FILE_NAME) if files.exists(default_plugin_path): return files.get_abs_path(files.PLUGINS_DIR, plugin_name) @@ -396,7 +392,9 @@ def delete_plugin(plugin_name: str): raise ValueError("Only custom plugins can be deleted") # delete additional plugin folders - assets = [asset for asset in find_plugin_assets("", plugin_name=plugin_name) if not asset["path"].startswith(plugin_dir)] + assets = [ + asset for asset in find_plugin_assets("", plugin_name=plugin_name) if not asset["path"].startswith(plugin_dir) + ] for asset in assets: files.delete_dir(asset["path"]) @@ -405,7 +403,7 @@ def delete_plugin(plugin_name: str): ) # send before deletion to properly check the extensions, second notification will be skipped automatically # does it have python files? - python_change = bool(files.find_existing_paths_by_pattern(plugin_dir+"/**/*.py")) + python_change = bool(files.find_existing_paths_by_pattern(plugin_dir + "/**/*.py")) # delete main plugin folder files.delete_dir(plugin_dir) @@ -417,16 +415,12 @@ def get_plugin_paths(*subpaths: str) -> List[str]: sub = "*/" + "/".join(subpaths) if subpaths else "*" paths: List[str] = [] for root in get_plugin_roots(): - paths.extend( - files.find_existing_paths_by_pattern(files.get_abs_path(root, sub)) - ) + paths.extend(files.find_existing_paths_by_pattern(files.get_abs_path(root, sub))) return paths def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]: - if cached := cache.get( - ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths) - ): + if cached := cache.get(ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths)): return cached enabled = get_enabled_plugins(agent) @@ -455,9 +449,7 @@ def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]: def get_enabled_plugins(agent: Agent | None): - if cached := cache.get( - ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent) - ): + if cached := cache.get(ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent)): return cached plugins = get_plugins_list() @@ -502,9 +494,7 @@ def determined_toggle_from_paths(default: bool, paths: Iterator[str]): enabled = default for plugin_path in paths: if enabled: - enabled = not files.exists( - files.get_abs_path(plugin_path, DISABLED_FILE_NAME) - ) + enabled = not files.exists(files.get_abs_path(plugin_path, DISABLED_FILE_NAME)) else: enabled = files.exists(files.get_abs_path(plugin_path, ENABLED_FILE_NAME)) return enabled @@ -519,11 +509,7 @@ def get_toggle_state(plugin_name: str) -> ToggleState: # root plugin paths plugin_paths = get_plugin_roots(plugin_name) - state = ( - "enabled" - if determined_toggle_from_paths(True, reversed(plugin_paths)) - else "disabled" - ) + state = "enabled" if determined_toggle_from_paths(True, reversed(plugin_paths)) else "disabled" # additional toggles in project/agent directories, return advanced if meta.per_agent_config or meta.per_project_config: @@ -561,12 +547,8 @@ def toggle_plugin( for toggle in all_toggles: files.delete_file(toggle["path"]) - enabled_file = determine_plugin_asset_path( - plugin_name, project_name, agent_profile, ENABLED_FILE_NAME - ) - disabled_file = determine_plugin_asset_path( - plugin_name, project_name, agent_profile, DISABLED_FILE_NAME - ) + enabled_file = determine_plugin_asset_path(plugin_name, project_name, agent_profile, ENABLED_FILE_NAME) + disabled_file = determine_plugin_asset_path(plugin_name, project_name, agent_profile, DISABLED_FILE_NAME) # ensure clean state by deleting both potential files first files.delete_file(enabled_file) @@ -607,16 +589,12 @@ def get_plugin_config( # use default config if not found if not file_path: - file_path = files.get_abs_path( - find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME - ) + file_path = files.get_abs_path(find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME) default_used = True result = None if file_path and files.exists(file_path): - result = ( - json.loads if file_path.lower().endswith(".json") else yaml_helper.loads - )(files.read_file(file_path)) + result = (json.loads if file_path.lower().endswith(".json") else yaml_helper.loads)(files.read_file(file_path)) if default_used: _apply_defaults_from_env(plugin_name, result) @@ -635,31 +613,21 @@ def get_plugin_config( def get_default_plugin_config(plugin_name: str): - file_path = files.get_abs_path( - find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME - ) + file_path = files.get_abs_path(find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME) # call plugin hook to get the result - result = call_plugin_hook( - plugin_name, "get_default_plugin_config", file_path=file_path - ) + result = call_plugin_hook(plugin_name, "get_default_plugin_config", file_path=file_path) # or do standard load if result is None and file_path and files.exists(file_path): - result = ( - json.loads if file_path.lower().endswith(".json") else yaml_helper.loads - )(files.read_file(file_path)) + result = (json.loads if file_path.lower().endswith(".json") else yaml_helper.loads)(files.read_file(file_path)) return result @extension.extensible -def save_plugin_config( - plugin_name: str, project_name: str, agent_profile: str, settings: dict -): - file_path = determine_plugin_asset_path( - plugin_name, project_name, agent_profile, CONFIG_FILE_NAME - ) +def save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict): + file_path = determine_plugin_asset_path(plugin_name, project_name, agent_profile, CONFIG_FILE_NAME) # call plugin hook to get the result first new_settings = call_plugin_hook( @@ -677,9 +645,7 @@ def save_plugin_config( # after_plugin_change([plugin_name]) # don't trigger when only config changes -def find_plugin_asset( - plugin_name: str, *subpaths: str, project_name="", agent_profile="" -): +def find_plugin_asset(plugin_name: str, *subpaths: str, project_name="", agent_profile=""): result = find_plugin_assets( *subpaths, plugin_name=plugin_name, @@ -704,9 +670,7 @@ def find_plugin_assets( def _collect(path: str, proj: str, profile: str) -> bool: is_glob = glob.has_magic(path) matched_paths = ( - files.find_existing_paths_by_pattern(path) - if is_glob - else ([path] if files.exists(path) else []) + files.find_existing_paths_by_pattern(path) if is_glob else ([path] if files.exists(path) else []) ) need_proj = proj == "*" @@ -722,9 +686,7 @@ def _after(s: str, marker: str, last: bool = False) -> str: for matched in matched_paths: inferred_proj = _after(matched, "/projects/") if need_proj else proj - inferred_prof = ( - _after(matched, "/agents/", last=True) if need_prof else profile - ) + inferred_prof = _after(matched, "/agents/", last=True) if need_prof else profile results.append( { "project_name": inferred_proj, @@ -751,9 +713,7 @@ def _after(s: str, marker: str, last: bool = False) -> str: return results if not agent_profile or agent_profile == "*": # project/.ctx0proj/plugins//... - path = projects.get_project_meta( - project_name, files.PLUGINS_DIR, plugin_name, *subpaths - ) + path = projects.get_project_meta(project_name, files.PLUGINS_DIR, plugin_name, *subpaths) if _collect(path, project_name, ""): return results @@ -805,9 +765,7 @@ def _after(s: str, marker: str, last: bool = False) -> str: return results -def determine_plugin_asset_path( - plugin_name: str, project_name: str, agent_profile: str, *subpaths: str -): +def determine_plugin_asset_path(plugin_name: str, project_name: str, agent_profile: str, *subpaths: str): base_path = files.get_abs_path(files.USER_DIR) if project_name: @@ -834,9 +792,7 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None): has_webui_extension = False for plugin_name in plugin_names: plugin_dir = find_plugin_dir(plugin_name) - if plugin_dir and files.exists( - files.get_abs_path(plugin_dir, "extensions", "webui") - ): + if plugin_dir and files.exists(files.get_abs_path(plugin_dir, "extensions", "webui")): has_webui_extension = True break if not has_webui_extension: @@ -863,9 +819,7 @@ async def _send_later(): DeferredTask().start_task(_send_later) -def call_plugin_hook( - plugin_name: str, hook_name: str, default: Any = None, *args, **kwargs -): +def call_plugin_hook(plugin_name: str, hook_name: str, default: Any = None, *args, **kwargs): hooks = None # use cached hooks if enabled @@ -874,9 +828,7 @@ def call_plugin_hook( if not plugin_dir: return default # plugin directory not found, skip hooks hooks_script = files.get_abs_path(plugin_dir, HOOKS_SCRIPT) - hooks = ( - modules.import_module(hooks_script) if files.exists(hooks_script) else None - ) + hooks = modules.import_module(hooks_script) if files.exists(hooks_script) else None cache.add(HOOKS_CACHE_AREA, plugin_name, hooks) else: hooks = cache.get(HOOKS_CACHE_AREA, plugin_name) From 64d9477333e8dc60eb62bac390c418910bda401a Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:48 +0600 Subject: [PATCH 3/9] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20dependency=20?= =?UTF-8?q?versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index a8ba6d30..fe9062e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,8 +23,8 @@ mcp==1.26.0 newspaper3k==0.2.8 paramiko==3.5.0 playwright==1.52.0 -pypdf==6.9.2 -python-dotenv==1.1.0 +pypdf==6.9.1 +python-dotenv==1.2.1 pytz==2024.2 sentence-transformers==3.0.1 tiktoken==0.8.0 @@ -34,7 +34,7 @@ webcolors==24.6.0 nest-asyncio==1.6.0 crontab==1.0.1 markdownify>=1.1.0 -pydantic==2.11.7 +pydantic==2.12.5 pymupdf==1.25.3 pytesseract==0.3.13 pdf2image==1.17.0 From b698be6b14e4f97b107ae448388443f7d82a1a7f Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:51 +0600 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=90=B3=20Fix=20locale=20generation=20?= =?UTF-8?q?and=20Dockerfile=20copy=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/base/Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 7e94ed80..a72aac8a 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,8 +5,10 @@ FROM kalilinux/kali-rolling # Set locale to en_US.UTF-8 and timezone to UTC RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y locales tzdata RUN sed -i -e 's/# \(en_US\.UTF-8 .*\)/\1/' /etc/locale.gen && \ - dpkg-reconfigure --frontend=noninteractive locales && \ - update-locale LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 + locale-gen && \ + echo 'LANG=en_US.UTF-8' > /etc/default/locale && \ + echo 'LANGUAGE=en_US:en' >> /etc/default/locale && \ + echo 'LC_ALL=en_US.UTF-8' >> /etc/default/locale RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime RUN echo "UTC" > /etc/timezone RUN dpkg-reconfigure -f noninteractive tzdata @@ -16,7 +18,7 @@ ENV LC_ALL=en_US.UTF-8 ENV TZ=UTC # Copy contents of the project to / -COPY ./fs/ / +COPY docker/base/fs/ / # install packages software (split for better cache management) RUN bash /ins/install_base_packages1.sh From ea2459e6fca62d7ceec084b419ab0a50eaa1c581 Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:54 +0600 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=94=A7=20Enable=20debug=20environment?= =?UTF-8?q?=20variables=20for=20UI=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/run/fs/etc/supervisor/conf.d/supervisord.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/run/fs/etc/supervisor/conf.d/supervisord.conf b/docker/run/fs/etc/supervisor/conf.d/supervisord.conf index 0de305c2..34795ddc 100644 --- a/docker/run/fs/etc/supervisor/conf.d/supervisord.conf +++ b/docker/run/fs/etc/supervisor/conf.d/supervisord.conf @@ -60,7 +60,7 @@ killasgroup=true [program:run_ui] command=/exe/run_CTX0.sh -environment= +environment=CTX0_DEBUG="true",CTX0_WS_DEBUG="true",LOGLEVEL="DEBUG",UVICORN_LOG_LEVEL="debug" user=root stopwaitsecs=60 stdout_logfile=/dev/stdout From 3407d1b8a8075c9d6b0a84c08bdea5d177a2a2ce Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:56 +0600 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=94=93=20Switch=20Docker=20UI=20to=20?= =?UTF-8?q?development=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/run/fs/exe/self_update_manager.py | 51 +++++++++--------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/docker/run/fs/exe/self_update_manager.py b/docker/run/fs/exe/self_update_manager.py index cae5a4dc..de5922ca 100644 --- a/docker/run/fs/exe/self_update_manager.py +++ b/docker/run/fs/exe/self_update_manager.py @@ -33,12 +33,8 @@ "CTX0_SELF_UPDATE_HEALTH_URL", "http://127.0.0.1:80/api/health", ) -DEFAULT_HEALTH_TIMEOUT_SECONDS = int( - os.environ.get("CTX0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "120") -) -DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float( - os.environ.get("CTX0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2") -) +DEFAULT_HEALTH_TIMEOUT_SECONDS = int(os.environ.get("CTX0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "120")) +DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float(os.environ.get("CTX0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2")) DEFAULT_BACKUP_DIR = "/root/update-backups" DEFAULT_BACKUP_CONFLICT_POLICY = "rename" BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"} @@ -216,8 +212,7 @@ def get_latest_same_major_tag( ] if not same_major_tags: raise RuntimeError( - f"No v{current_major}.x release tags are reachable from branch " - f"{branch_ref.rsplit('/', 1)[-1]}." + f"No v{current_major}.x release tags are reachable from branch {branch_ref.rsplit('/', 1)[-1]}." ) return sort_selector_supported_tags(same_major_tags)[0] @@ -238,8 +233,7 @@ def ensure_latest_target_matches_current_major( target_major = parse_major_version(target_version) if target_major is None or not is_supported_selector_tag(target_version): raise RuntimeError( - f"Could not resolve latest on branch {branch} to a supported vX.Y release. " - "Use an explicit tag instead." + f"Could not resolve latest on branch {branch} to a supported vX.Y release. Use an explicit tag instead." ) if target_major != current_major: @@ -398,8 +392,7 @@ def run_command( logger.log_block("stderr", completed.stderr) if completed.returncode != 0: raise RuntimeError( - error_message - or f"Command failed with exit code {completed.returncode}: {' '.join(command)}" + error_message or f"Command failed with exit code {completed.returncode}: {' '.join(command)}" ) return completed @@ -439,8 +432,7 @@ def create_rollback_stash(repo_dir: Path, logger: AttemptLogger) -> str | None: if not stash_ref or stash_ref == previous_top: raise RuntimeError("Failed to create the pre-update rollback stash.") logger.log( - f"Saved local tracked/untracked changes into {stash_ref}. " - "Ignored files stay in place and are not stashed." + f"Saved local tracked/untracked changes into {stash_ref}. Ignored files stay in place and are not stashed." ) return stash_ref @@ -471,9 +463,7 @@ def apply_stash(repo_dir: Path, stash_ref: str, logger: AttemptLogger) -> None: try: drop_stash(repo_dir, stash_ref, logger) except Exception as exc: - logger.log( - f"Rollback stash {stash_ref} was restored but could not be dropped automatically: {exc}" - ) + logger.log(f"Rollback stash {stash_ref} was restored but could not be dropped automatically: {exc}") def clean_repo_worktree( @@ -598,9 +588,7 @@ def resolve_requested_target( current_version=current_version, target_version=head_short_tag, ) - logger.log( - f"Resolved latest on branch {branch} to commit {head_commit[:7]} ({head_describe})" - ) + logger.log(f"Resolved latest on branch {branch} to commit {head_commit[:7]} ({head_describe})") return { "requested_tag": LATEST_SELECTOR_TAG, "effective_tag": head_short_tag, @@ -692,7 +680,8 @@ def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen [ sys.executable, str(repo_dir / "run_ui.py"), - "--dockerized=true", + "--dockerized=false", + "--development=true", "--port=80", "--host=0.0.0.0", ], @@ -873,7 +862,10 @@ def execute_pending_update( "Git checkout completed but the repository commit does not match the requested target. " f"Expected {resolved_target['expected_commit']}, got {current_info['commit']}." ) - if resolved_target.get("expected_short_tag") and current_info["short_tag"] != resolved_target["expected_short_tag"]: + if ( + resolved_target.get("expected_short_tag") + and current_info["short_tag"] != resolved_target["expected_short_tag"] + ): raise RuntimeError( "Git checkout completed but the repository version does not match the requested tag. " f"Expected {resolved_target['expected_short_tag']}, got {current_info['short_tag']}." @@ -908,9 +900,7 @@ def execute_pending_update( try: drop_stash(REPO_DIR, stash_ref, logger) except Exception as exc: - logger.log( - f"Temporary rollback stash {stash_ref} could not be dropped automatically: {exc}" - ) + logger.log(f"Temporary rollback stash {stash_ref} could not be dropped automatically: {exc}") return updated_process logger.log(f"Updated UI failed health check, rolling back: {details}") @@ -939,8 +929,7 @@ def execute_pending_update( record_result( status="rolled_back", message=( - "Updated version failed its health check and the previous version was restored. " - f"Reason: {details}" + f"Updated version failed its health check and the previous version was restored. Reason: {details}" ), request_data=request_data, source_info=source_info, @@ -955,9 +944,7 @@ def execute_pending_update( terminate_process(rollback_process) record_result( status="rollback_failed", - message=( - "Updated version failed its health check and rollback also failed to become healthy." - ), + message=("Updated version failed its health check and rollback also failed to become healthy."), request_data=request_data, source_info=source_info, current_version=source_info["short_tag"], @@ -1159,9 +1146,7 @@ def docker_run_ui() -> int: requested_branch=requested_branch, requested_tag=requested_tag, ): - logger.log( - "Requested tag already matches the installed version, skipping file replacement." - ) + logger.log("Requested tag already matches the installed version, skipping file replacement.") record_result( status="skipped", message="Requested tag already matches the installed version.", From dccad52315c9fdcb89031063008c27a23e983992 Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:19:59 +0600 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=94=8D=20Enable=20debug=20logging=20i?= =?UTF-8?q?n=20development=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run_ui.py | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/run_ui.py b/run_ui.py index 6f0ab910..3cadc543 100644 --- a/run_ui.py +++ b/run_ui.py @@ -30,14 +30,18 @@ # disable logging import logging -logging.getLogger().setLevel(logging.WARNING) + +if runtime.is_development(): + logging.getLogger().setLevel(logging.DEBUG) +else: + logging.getLogger().setLevel(logging.WARNING) # Set the new timezone to 'UTC' os.environ["TZ"] = "UTC" os.environ["TOKENIZERS_PARALLELISM"] = "false" # Apply the timezone change -if hasattr(time, 'tzset'): +if hasattr(time, "tzset"): time.tzset() # initialize the internal Flask server @@ -52,7 +56,8 @@ webapp.config.update( JSON_SORT_KEYS=False, - SESSION_COOKIE_NAME="session_" + runtime.get_runtime_id(), # bind the session cookie name to runtime id to prevent session collision on same host + SESSION_COOKIE_NAME="session_" + + runtime.get_runtime_id(), # bind the session cookie name to runtime id to prevent session collision on same host SESSION_COOKIE_SAMESITE="Lax", SESSION_PERMANENT=True, PERMANENT_SESSION_LIFETIME=timedelta(days=1), @@ -66,10 +71,10 @@ async_mode="asgi", namespaces="*", cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0], - logger=False, - engineio_logger=False, + logger=runtime.is_development(), + engineio_logger=runtime.is_development(), ping_interval=25, # explicit default to avoid future lib changes - ping_timeout=20, # explicit default to avoid future lib changes + ping_timeout=20, # explicit default to avoid future lib changes max_http_buffer_size=50 * 1024 * 1024, ) @@ -77,9 +82,7 @@ set_shared_websocket_manager(websocket_manager) _settings = settings_helper.get_settings() settings_helper.set_runtime_settings_snapshot(_settings) -websocket_manager.set_server_restart_broadcast( - _settings.get("websocket_server_restart_enabled", True) -) +websocket_manager.set_server_restart_broadcast(_settings.get("websocket_server_restart_enabled", True)) # Set up basic authentication for UI and API but not MCP # basic_auth = BasicAuth(webapp) @@ -89,16 +92,16 @@ @extension.extensible async def login_handler(): error = None - if request.method == 'POST': + if request.method == "POST": user = dotenv.get_dotenv_value("AUTH_LOGIN") password = dotenv.get_dotenv_value("AUTH_PASSWORD") - if request.form['username'] == user and request.form['password'] == password: - session['authentication'] = login.get_credentials_hash() - return redirect(url_for('serve_index')) + if request.form["username"] == user and request.form["password"] == password: + session["authentication"] = login.get_credentials_hash() + return redirect(url_for("serve_index")) else: await asyncio.sleep(1) - error = 'Invalid Credentials. Please try again.' + error = "Invalid Credentials. Please try again." login_page_content = files.read_file("webui/login.html") return render_template_string(login_page_content, error=error) @@ -107,8 +110,8 @@ async def login_handler(): @webapp.route("/logout") @extension.extensible async def logout_handler(): - session.pop('authentication', None) - return redirect(url_for('login_handler')) + session.pop("authentication", None) + return redirect(url_for("login_handler")) # handle default address, load index @@ -142,11 +145,13 @@ async def serve_index(): async def serve_builtin_plugin_asset(plugin_name, asset_path): return await _serve_plugin_asset(plugin_name, asset_path) + @webapp.route("/usr/plugins//", methods=["GET"]) @requires_auth async def serve_plugin_asset(plugin_name, asset_path): return await _serve_plugin_asset(plugin_name, asset_path) + @webapp.route("/extensions/webui/", methods=["GET"]) @requires_auth async def serve_extension_asset(asset_path): @@ -164,27 +169,28 @@ async def _serve_plugin_asset(plugin_name, asset_path): Resolves using the plugin system (with overrides). """ from helpers import plugins - - + # Use the new find_plugin helper plugin_dir = plugins.find_plugin_dir(plugin_name) if not plugin_dir: return Response("Plugin not found", 404) - + # Resolve the plugin asset path with security checks try: # Construct path using plugin root asset_file = files.get_abs_path(plugin_dir, asset_path) webui_dir = files.get_abs_path(plugin_dir, "webui") webui_extensions_dir = files.get_abs_path(plugin_dir, "extensions/webui") - + # Security: ensure the resolved path is within the plugin webui directory - if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir(str(asset_file), str(webui_extensions_dir)): + if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir( + str(asset_file), str(webui_extensions_dir) + ): return Response("Access denied", 403) - + if not files.is_file(asset_file): return Response("Asset not found", 404) - + return send_file(str(asset_file)) except Exception as e: PrintStyle.error(f"Error serving plugin asset: {e}") @@ -249,9 +255,7 @@ async def _handle_connect_with_namespace_gatekeeper(eio_sid, namespace, data): socketio_server._handle_connect = _handle_connect_with_namespace_gatekeeper # type: ignore[assignment] - def _register_namespace_handlers( - namespace: str, namespace_handlers: list[WebSocketHandler] - ) -> None: + def _register_namespace_handlers(namespace: str, namespace_handlers: list[WebSocketHandler]) -> None: # A namespace is the WebSocket equivalent of an API endpoint. # Security requirements must be consistent within the namespace (no any()-based union). auth_required = False @@ -260,10 +264,7 @@ def _register_namespace_handlers( auth_required = bool(namespace_handlers[0].requires_auth()) csrf_required = bool(namespace_handlers[0].requires_csrf()) for handler in namespace_handlers[1:]: - if ( - bool(handler.requires_auth()) != auth_required - or bool(handler.requires_csrf()) != csrf_required - ): + if bool(handler.requires_auth()) != auth_required or bool(handler.requires_csrf()) != csrf_required: raise ValueError( f"WebSocket namespace {namespace!r} has mixed auth/csrf requirements across handlers" ) @@ -294,9 +295,7 @@ async def _connect( # type: ignore[override] ) return False else: - PrintStyle.debug( - "WebSocket authentication required but credentials not configured; proceeding" - ) + PrintStyle.debug("WebSocket authentication required but credentials not configured; proceeding") if _csrf_required: expected_token = session.get("csrf_token") @@ -367,9 +366,7 @@ def run(): # Get configuration from environment port = runtime.get_web_ui_port() - host = ( - runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost" - ) + host = runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost" register_api_route(webapp, lock) @@ -402,6 +399,7 @@ def flush_and_shutdown_callback() -> None: TODO(dev): add cleanup + flush-to-disk logic here. """ return + flush_ran = False def _run_flush(reason: str) -> None: @@ -418,8 +416,8 @@ def _run_flush(reason: str) -> None: asgi_app, host=host, port=port, - log_level="info", - access_log=_settings.get("uvicorn_access_logs_enabled", False), + log_level="debug" if runtime.is_development() else "info", + access_log=True if runtime.is_development() else _settings.get("uvicorn_access_logs_enabled", False), ws="wsproto", ) server = uvicorn.Server(config) From 48c236c810593c9ae3fa13e544bdf26b1bbe89c9 Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 10:20:02 +0600 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A7=AA=20Add=20docker-test=20target?= =?UTF-8?q?=20to=20Makefile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4ce26d9b..00fc2424 100644 --- a/Makefile +++ b/Makefile @@ -160,9 +160,21 @@ update-deps: ## Update dependencies fi # Docker +DOCKER_BASE_IMAGE := ctxos/ctxai-base +DOCKER_BASE_TAG := latest +DOCKER_PLATFORMS := linux/amd64,linux/arm64 + +docker-base: ## Build base Docker image for current platform + @echo "$(BLUE)Building base Docker image $(DOCKER_BASE_IMAGE):$(DOCKER_BASE_TAG)...$(NC)" + docker buildx build --builder mybuilder -f docker/base/Dockerfile -t $(DOCKER_BASE_IMAGE):$(DOCKER_BASE_TAG) --load . + +docker-base-push: ## Build and push multi-arch base Docker image + @echo "$(BLUE)Building and pushing multi-arch base Docker image $(DOCKER_BASE_IMAGE):$(DOCKER_BASE_TAG)...$(NC)" + docker buildx build --builder mybuilder -f docker/base/Dockerfile -t $(DOCKER_BASE_IMAGE):$(DOCKER_BASE_TAG) --platform $(DOCKER_PLATFORMS) --push . + docker-build: ## Build Docker image @echo "$(BLUE)Building Docker image $(DOCKER_IMAGE):$(DOCKER_TAG)...$(NC)" - docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) . + docker build -f DockerfileLocal -t $(DOCKER_IMAGE):$(DOCKER_TAG) . docker-run: ## Run CtxAI with Docker @echo "$(BLUE)Running CtxAI with Docker...$(NC)" @@ -177,6 +189,10 @@ docker-stop: ## Stop Docker Compose services @echo "$(BLUE)Stopping Docker Compose services...$(NC)" cd docker/run && docker-compose down +docker-test: ## Run tests inside Docker container + @echo "$(BLUE)Running tests in Docker container...$(NC)" + docker run --rm --entrypoint python $(DOCKER_IMAGE):$(DOCKER_TAG) -m pytest -v + # Documentation docs: ## Generate documentation @echo "$(BLUE)Generating documentation...$(NC)" From ae22df71788a7857b4ac3c7379e6d7adb3807398 Mon Sep 17 00:00:00 2001 From: Kali Date: Wed, 1 Apr 2026 16:03:00 +0600 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=8E=A8=20Clean=20up=20message=20handl?= =?UTF-8?q?er=20formatting=20and=20indentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/message.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/api/message.py b/api/message.py index 9d15d07a..7e2b399d 100644 --- a/api/message.py +++ b/api/message.py @@ -28,10 +28,8 @@ async def communicate(self, input: dict, request: Request): attachments = request.files.getlist("attachments") attachment_paths = [] - upload_folder_int = "/ctx0/usr/uploads" - upload_folder_ext = files.get_abs_path( - "usr/uploads" - ) # for development environment + upload_folder_int = "/ctx0/usr/uploads" + upload_folder_ext = files.get_abs_path("usr/uploads") # for development environment if attachments: os.makedirs(upload_folder_ext, exist_ok=True) @@ -60,9 +58,7 @@ async def communicate(self, input: dict, request: Request): # call extension point, alow it to modify data data = {"message": message, "attachment_paths": attachment_paths} - await extension.call_extensions_async( - "user_message_ui", agent=context.get_agent(), data=data - ) + await extension.call_extensions_async("user_message_ui", agent=context.get_agent(), data=data) message = data.get("message", "") attachment_paths = data.get("attachment_paths", []) @@ -73,7 +69,5 @@ async def communicate(self, input: dict, request: Request): mq.log_user_message(context, message, attachment_paths, message_id) return context.communicate( - UserMessage( - message=message, attachments=attachment_paths, id=message_id or "" - ) + UserMessage(message=message, attachments=attachment_paths, id=message_id or "") ), context