diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index 93d51f9..ff98386 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -49,8 +49,15 @@ MAX_HISTORY_CHARS = 80_000 # ═════════════════════════════════════════════════════════════════════════════ def build_all_tool_description() -> str: - """Get relevant tools from the MCP servers based on the query.""" + """Build a formatted string listing every registered MCP tool. + The returned string is embedded verbatim in the SYSTEM_PROMPT so the LLM + knows which tools exist and what arguments they expect. + + Returns: + Newline-separated list of tool descriptions in the format + ``"- : "``. + """ all_tools = adapter.get_all_tools() print(f"Building tool description for {len(all_tools)} tools.") @@ -58,17 +65,30 @@ def build_all_tool_description() -> str: for tool in all_tools: pprint.pprint(f"{tool}") descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}") - + return "\n".join(descriptions) async def dispatch_tool(tool_name: str, arguments: dict) -> str: - """Call a tool by name with the given arguments using the MCP adapter.""" + """Execute a named tool and return its output as a plain string. + Handles the special "done" pseudo-tool locally (it signals completion and + is never forwarded to an MCP server). All other tools are forwarded to the + MCPToolAdapter which routes them to the correct MCP server process. + + Args: + tool_name: Name of the tool to execute (e.g. "write_file", "done"). + arguments: Dict of arguments for the tool. + + Returns: + The tool's text output, a "DONE: ..." completion message, or an error + string beginning with "Tool error:" / "Error calling tool:" on failure. + """ if tool_name == "done": - # Handle the "done" tool locally since it's not an MCP tool + # The "done" tool is a sentinel — it lives only in the agent protocol, + # not in any MCP server, so we resolve it directly here. summary = arguments.get("summary", "Task completed.") return f"DONE: {summary}" - + try: print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...") result = await adapter.call_tool(tool_name, arguments) @@ -76,12 +96,14 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str: print(f"Raw result from tool '{tool_name}': {result}") if result.isError: + # MCP servers signal tool-level errors via the isError flag rather + # than raising exceptions, so we surface them explicitly. texts = [block.text for block in result.content if block.type == "text"] return f"Tool error: {' '.join(texts)}" - + texts = [block.text for block in result.content if block.type == "text"] - return "\n".join(texts) - + return "\n".join(texts) + except Exception as e: return f"Error calling tool '{tool_name}': {e}" @@ -167,23 +189,33 @@ def truncate_result(result: str) -> str: def trim_messages(messages: list) -> list: - """Drop old messages when history exceeds MAX_HISTORY_CHARS. - Always keeps the system prompt (index 0) and original task (index 1). + """Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet. + + Behält immer den System-Prompt (Index 0) und die ursprüngliche Aufgabe (Index 1). + Entfernt die ältesten Nachrichten zuerst und injiziert danach einen Erinnerungs- + Hinweis damit der Agent den Überblick behält. + + Args: + messages: Vollständige Konversations-History als Liste von {role, content} Dicts. + + Returns: + Gekürzte History mit maximal MAX_HISTORY_CHARS Zeichen, immer mit Head + Reminder + Tail. """ total = sum(len(m["content"]) for m in messages) if total <= MAX_HISTORY_CHARS: return messages + # Protect the two anchor messages that must never be discarded. head = messages[:2] tail = messages[2:] original_task = messages[1]["content"] if len(messages) > 1 else "" - # Drop the oldest messages first (index 2 onwards) until we are under the limit. - # The system prompt (0) and original task (1) are never dropped. + # Drop the oldest non-anchor messages first until we are under the limit. while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS: tail.pop(0) - # Inject a reminder so the agent doesn't lose track of its goal after trimming. + # After trimming, inject a reminder so the agent doesn't lose track of its goal. + # Without this the agent might restart the task or repeat work it already did. reminder = { "role": "user", "content": ( @@ -196,13 +228,22 @@ def trim_messages(messages: list) -> list: return head + [reminder] + tail def _repair_json_strings(text: str) -> str: - """ - Replace unescaped control characters (newline, tab, carriage return) - inside JSON string values with their proper escape sequences. + """Replace unescaped control characters inside JSON string values. - LLMs frequently emit literal newlines inside long string values, which - is invalid JSON. This function fixes that without touching structural - whitespace outside strings. + LLMs frequently emit literal newlines, tabs, or carriage-returns inside + long string values (e.g. code content), which is invalid JSON. This + function replaces those characters with their proper ``\\n`` / ``\\t`` / + ``\\r`` escape sequences without touching structural whitespace that lives + outside of string literals. + + The parser is a simple state-machine that tracks whether the current + character is inside a quoted string, respecting backslash escapes. + + Args: + text: Raw JSON text that may contain unescaped control characters. + + Returns: + Repaired JSON text with control characters properly escaped inside strings. """ result: list[str] = [] in_string = False @@ -210,6 +251,8 @@ def _repair_json_strings(text: str) -> str: _escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'} for ch in text: if escape: + # The previous character was a backslash — emit this char literally + # and reset the escape flag. result.append(ch) escape = False continue @@ -218,10 +261,12 @@ def _repair_json_strings(text: str) -> str: escape = True continue if ch == '"': + # Toggle string-mode on every unescaped double quote. in_string = not in_string result.append(ch) continue if in_string and ch in _escapes: + # Replace the bare control character with its escape sequence. result.append(_escapes[ch]) continue result.append(ch) @@ -285,13 +330,25 @@ def extract_json(text: str) -> str: def _strip_code_fences(text: str) -> str: - """Remove markdown code fences (```json ... ```) from a string.""" + """Remove a single wrapping markdown code fence from a string. + + Handles both `` ```json `` and plain `` ``` `` opening fences. If the text + does not start with a fence the string is returned unchanged. + + Args: + text: Raw LLM response that may be wrapped in a markdown code block. + + Returns: + The text with the opening fence line and optional closing `` ``` `` line + removed, stripped of surrounding whitespace. + """ if text is None: return "" - + text = text.strip() if text.startswith("```"): lines = text.split("\n") + # Omit the last line only if it is a closing fence; otherwise keep everything. end = -1 if lines[-1].strip() == "```" else len(lines) text = "\n".join(lines[1:end]) return text.strip() @@ -328,11 +385,18 @@ class CodingAgent: self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") - #async def _call_api(self, messages: list) -> str: def _call_api(self, messages: list) -> str: + """Send the conversation history to the LLM and return its reply text. - """Make a raw API call and return the response content string.""" - + Args: + messages: Full conversation history as a list of {role, content} dicts. + + Returns: + The raw string content of the assistant's reply. + + Raises: + Exception: On non-200 HTTP status or an unexpected response schema. + """ headers = {"Content-Type": "application/json"} if self.api_key and self.api_key != "EMPTY": headers["Authorization"] = f"Bearer {self.api_key}" @@ -340,11 +404,13 @@ class CodingAgent: payload = { "model": self.model, "messages": messages, - "temperature": 0.2, # low temperature → deterministic, more reliable tool calls + # Low temperature keeps the agent's tool selections deterministic and + # reduces the chance of hallucinated tool names or argument formats. + "temperature": 0.2, "max_tokens": 4096, "stream": False, } - + response = requests.post(self.api_url, headers=headers, json=payload, timeout=60) if response.status_code != 200: diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index 8df0885..e3be379 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -1,3 +1,15 @@ +"""Adapter layer between the CodingAgent and one or more MCP tool servers. + +MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns +each process via stdio, queries its available tools, and stores them in a flat +registry. At call time it re-spawns the appropriate server process, executes +the requested tool, and returns the raw MCP result object. + +Design note: connections are opened per-call (not kept alive) because Streamlit +reruns make it impractical to maintain long-lived async context managers across +the synchronous/asynchronous boundary. +""" + import asyncio import json import sys @@ -7,20 +19,42 @@ from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client + class MCPToolAdapter: + """Discovers and dispatches MCP tools from one or more stdio-based MCP servers. + + Workflow: + 1. Call ``initialize_all_servers()`` once at startup to populate the + tool registry from every server listed in the config file. + 2. Call ``get_all_tools()`` to retrieve the registry for building the + system-prompt tool description. + 3. Call ``call_tool(name, arguments)`` whenever the agent wants to + execute a tool. The adapter resolves the owning server, opens a + fresh connection, and returns the MCP result object. + + Attributes: + config_path: Path (relative to this file) of the JSON server config. + servers: Dict mapping server name → raw config params dict. + tool_registry: Flat list of registered tool dicts, each containing + "server", "tool_name", and "tool_description". + """ + def __init__(self, config_path: str = "mcp_server_config.json"): self.config_path = config_path self.servers: Dict[str, Dict] = {} - #self.exit_stack: Dict[str, Any] = {} self.tool_registry: List[Dict[str, Any]] = [] def _load_config(self) -> Dict[str, Any]: - """Lädt die Server-Konfiguration aus der JSON-Datei.""" + """Load the MCP server configuration from the JSON file next to this module. + + Returns: + Parsed config dict, or an empty dict if the file is missing or invalid. + """ path = Path(__file__).parent / self.config_path if not path.exists(): print(f"Config file not found: {path}") return {} - + try: with open(path, 'r') as f: return json.load(f) @@ -29,7 +63,13 @@ class MCPToolAdapter: return {} async def initialize_all_servers(self): - """Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren.""" + """Connect to every configured MCP server and register their tools. + + Opens a short-lived stdio connection to each server, calls list_tools(), + and stores each discovered tool in ``self.tool_registry``. Servers that + fail to connect are skipped with a warning so a single broken server does + not prevent the others from loading. + """ print("Initializing MCP sessions...") config = self._load_config() print(f"Loaded config for servers: {list(config.keys())}") @@ -39,18 +79,21 @@ class MCPToolAdapter: self.servers[server_name] = params server_script = str(Path(__file__).parent / params["args"][0]) + + # Always use the current Python interpreter so the server runs in the + # same virtual environment as the adapter, regardless of the literal + # command string in the config ("py", "python", "python3"). if params.get("command") in ["py", "python", "python3"]: server_command = sys.executable else: server_command = params["command"] - + server_params = StdioServerParameters( command=server_command, args=[server_script], ) - + try: - # Verbindung aufbauen async with stdio_client(server_params) as (read_stream, write_stream): print(f"Connected to {server_name}. Initializing session...") async with ClientSession(read_stream, write_stream) as session: @@ -60,9 +103,9 @@ class MCPToolAdapter: print(f"Tools received from {server_name}: {result}") tools = result.tools print(f"Tools received from {server_name}: {result}") - #tools = getattr(result, 'tools', []) for tool in tools: + # Build a human-readable parameter description for the system prompt. t_params = tool.inputSchema.get("properties", {}) if t_params: param_lines = [] @@ -76,7 +119,6 @@ class MCPToolAdapter: t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}" - self.tool_registry.append({ "server": server_name, "tool_name": tool.name, @@ -84,22 +126,37 @@ class MCPToolAdapter: }) print(f"Registered tool '{tool.name}' from {server_name}.") - - print(f"Session for {server_name} ready. {len(tools)} tools found.") + print(f"Session for {server_name} ready. {len(tools)} tools found.") except Exception as e: print(f"Failed to initialize {server_name}: {e}") def get_all_tools(self) -> List[Dict[str, Any]]: - """Gibt alle gesammelten Tools zurück.""" + """Return the full list of registered tools across all servers. + + Returns: + List of dicts, each with keys "server", "tool_name", "tool_description". + """ return self.tool_registry async def call_tool(self, tool_name: str, arguments: Dict[str, Any]): - """Findet den richtigen Server für ein Tool und führt es aus.""" - # Suche in der Registry nach dem passenden Server + """Look up a tool in the registry, connect to its server, and execute it. + + Opens a fresh stdio connection for every call. This is intentionally + stateless so that server crashes or restarts are fully transparent. + + Args: + tool_name: Name of the tool to call (must be in the registry). + arguments: Key-value arguments passed verbatim to the MCP server. + + Returns: + The raw MCP ``CallToolResult`` object on success, or an error string + if the tool is not found or the server raises an exception. + """ + # Look up which server owns this tool. tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None) - + if not tool_entry: print(f"Tool '{tool_name}' not found in MCP adapter registry.") return f"Error: Tool '{tool_name}' not found in registry." @@ -109,11 +166,12 @@ class MCPToolAdapter: if s_params: server_script = str(Path(__file__).parent / s_params["args"][0]) + # Normalise the interpreter command the same way as in initialize_all_servers(). if s_params.get("command") in ["py", "python", "python3"]: server_command = sys.executable else: server_command = s_params["command"] - + server_params = StdioServerParameters( command=server_command, args=[server_script], @@ -127,11 +185,16 @@ class MCPToolAdapter: return result except Exception as e: return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}" - + return f"Error: Session for server '{server_name}' not active." async def shutdown_all_sessions(self): - """Schließt alle offenen Verbindungen sauber.""" + """Close all open server connections gracefully. + + Note: This method references ``self.exit_stack`` which is not currently + populated (connections are opened per-call). It is kept as a placeholder + for a future persistent-connection implementation. + """ for server_name, (transport_gen, session) in self.exit_stack.items(): try: await session.__aexit__(None, None, None) diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index e04308a..ff02b9e 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -1,3 +1,19 @@ +"""MCP server that provides sandboxed Python code execution tools. + +Exposes the following MCP tools to the CodingAgent: + - analyse_structure — AST-based structural summary of Python code + - lint_code — pyflakes static analysis + - list_sandbox_packages — list packages installed in the sandbox venv + - install_package_into_sandbox — pip install into the sandbox venv + - reset_sandbox — wipe and recreate the sandbox venv + - run_python_code_sandboxed — execute Python code inside the sandbox + - python_code_validation — syntax + safety check without execution + +The sandbox is an isolated virtual environment created on first use. +All code submitted for execution is first checked by a static analyser that +blocks dangerous imports and builtins before spawning any subprocess. +""" + import ast from datetime import datetime import subprocess @@ -12,18 +28,30 @@ import shutil # ── Sandbox venv ──────────────────────────────────────────────────────────── SERVER_BASE_DIR = Path(__file__).parent.resolve() SANDBOX_DIR = SERVER_BASE_DIR / ".mcp_sandbox" +# Navigate four levels up from servers/ to reach the project root, then into workspace/. WORKSPACE_DIR = SERVER_BASE_DIR.parent.parent.parent.parent / "workspace" + def get_sandbox_paths(): - """Bestimmt die Executables innerhalb der Venv ohne os-Modul.""" + """Locate (and lazily create) the sandbox venv and return its executable paths. + + Creates the venv on first call if it does not yet exist. Uses a simple + filesystem check to determine whether we are on Windows (``Scripts/``) or + POSIX (``bin/``), avoiding the ``os`` module which is blocked inside the + sandbox itself. + + Returns: + Tuple of (python_exe_path, pip_exe_path) as strings. + """ if not SANDBOX_DIR.exists(): venv.create(SANDBOX_DIR, with_pip=True) - - bin_folder = "Scripts" if Path("C:/").exists() else "bin" # Einfacher Check für Windows - + + # Path("C:/").exists() is True on Windows, False on Linux/macOS. + bin_folder = "Scripts" if Path("C:/").exists() else "bin" + python_exe = SANDBOX_DIR / bin_folder / "python" pip_exe = SANDBOX_DIR / bin_folder / "pip" - + return str(python_exe), str(pip_exe) PYTHON_EXE, PIP_EXE = get_sandbox_paths() @@ -300,9 +328,17 @@ def install_package_into_sandbox(package_name: str) -> str: @mcp.tool() def reset_sandbox() -> str: - """Löscht die gesamte Sandbox und erstellt sie neu (Full Reset).""" + """Delete and recreate the sandbox virtual environment (full reset). + + Useful when a package installation went wrong or the venv became corrupted. + Calling get_sandbox_paths() after deletion triggers the lazy creation logic. + + Returns: + Confirmation string after the reset completes. + """ if SANDBOX_DIR.exists(): shutil.rmtree(SANDBOX_DIR) + # Re-calling get_sandbox_paths() triggers venv creation for the fresh sandbox. get_sandbox_paths() return "Sandbox wurde komplett zurückgesetzt." @@ -325,22 +361,26 @@ def run_python_code_sandboxed(code: str) -> str: Combined stdout+stderr, or an error message in str format. """ + # Reject code that references blocked modules or builtins before spawning a process. static_safety = check_code_safety(code) if static_safety: return f"Code rejected:{static_safety}" + # Each run gets its own temporary directory so concurrent runs don't interfere. run_id = datetime.now().strftime("%Y%m%d_%H%M%S") jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}" - + try: jail_dir.mkdir(parents=True, exist_ok=True) + # Minimal environment: only the sandbox Python is on PATH, HOME and TMPDIR + # point to the per-run jail directory so the subprocess cannot access user files. custom_env = { - "PYTHONPATH": str(WORKSPACE_DIR), - "PATH": str(Path(PYTHON_EXE).parent), - "HOME": str(jail_dir), - "TMPDIR": str(jail_dir) - } + "PYTHONPATH": str(WORKSPACE_DIR), + "PATH": str(Path(PYTHON_EXE).parent), + "HOME": str(jail_dir), + "TMPDIR": str(jail_dir) + } result = subprocess.run( [PYTHON_EXE, "-c", code], @@ -349,47 +389,54 @@ def run_python_code_sandboxed(code: str) -> str: capture_output=True, text=True, timeout=EXEC_TIMEOUT) - + + # Merge stdout and stderr so the agent sees all output in one block. output = result.stdout + result.stderr if len(output) > MAX_OUTPUT_LENGTH: output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..." - + if not output.strip(): return "Code executed successfully (no output)." - + return output - + except subprocess.TimeoutExpired: return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated." except Exception as e: return f"Error during code execution: {e}" - + finally: + # Always clean up the per-run jail directory, even if execution failed. if jail_dir.exists(): shutil.rmtree(jail_dir) @mcp.tool() def python_code_validation(code: str) -> str: - """ - Validate Python code for syntax and safety without executing it. - This tool performs static analysis to check for syntax errors. - + """Validate Python code for syntax correctness and sandbox safety without executing it. + + Performs two checks in sequence: + 1. AST parsing to catch syntax errors. + 2. check_code_safety() to detect blocked imports/builtins/path sequences. + Args: - code: The Python code to validate in str format. + code: The Python source code to validate. + Returns: - A message indicating the validation result. - And if sandboxed test execution is allowed. + A message indicating whether the code is valid and safe, or describing + the first violation found. Returns None implicitly when the code is + both syntactically valid and safe (no safety concerns found). """ try: ast.parse(code) except SyntaxError as e: return f"SyntaxError: {e}" - try: + try: static_analysis_result = check_code_safety(code) if static_analysis_result: + # Inform the agent that the code would be rejected by run_python_code_sandboxed. return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed." except Exception as e: return f"Error during code safety analysis: {e}" diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index 65180bc..dbcbdb1 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -1,10 +1,27 @@ +"""MCP server that provides file system read/write tools for the workspace directory. + +All operations are restricted to ALLOWED_DIR (the project workspace). Paths +that resolve outside this boundary are rejected with a ValueError so the agent +cannot accidentally read or write arbitrary host-filesystem locations. + +Exposes the following MCP tools: + - list_files — flat list of all workspace files + - get_file_tree — tree-formatted directory listing + - search_files — search file names and content + - read_file — read a single file + - write_new_file — create a new file (no overwrite) + - create_new_directory — create a new directory +""" + from pathlib import Path from mcp.server.fastmcp import FastMCP # ── Configuration ──────────────────────────────────────────────────────────── +# Navigate four levels up from servers/ to the project root, then into workspace/. project_dir = Path(__file__).resolve().parent.parent.parent.parent ALLOWED_DIR = project_dir / "workspace" -ALLOWED_FILE_TYPES = [".py",".js",".html",".css",".json",".yaml",".yml",".sh",".md",".txt",".tex",".c",".cpp",".java"] +ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml", + ".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"] # ── Create the MCP server ──────────────────────────────────────────────────── mcp = FastMCP("FileSearchServer") @@ -63,12 +80,30 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: def _tree(dir_path: Path, prefix="") -> str: - entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name)) + """Recursively build a tree string for the given directory. + + Directories are sorted before files (``key=lambda x: (x.is_file(), x.name)`` + puts dirs first because False < True). __pycache__ entries are hidden. + + Args: + dir_path: The directory to render. + prefix: Indentation prefix accumulated during recursion. + + Returns: + Multi-line string representing the subtree. + """ + # Exclude __pycache__ at every level to keep output readable for the agent. + entries = sorted( + [e for e in dir_path.iterdir() if "__pycache__" not in e.parts], + key=lambda x: (x.is_file(), x.name) # directories first, then files + ) lines = [] for i, entry in enumerate(entries): + # Use └── for the last entry to close the branch visually. connector = "└── " if i == len(entries) - 1 else "├── " lines.append(f"{prefix}{connector}{entry.name}") if entry.is_dir(): + # Extend prefix with a blank column (last item) or │ (more items follow). extension = " " if i == len(entries) - 1 else "│ " lines.append(_tree(entry, prefix + extension)) return "\n".join(lines) diff --git a/backend/agent/servers/mcp_server_web_search.py b/backend/agent/servers/mcp_server_web_search.py index 39c9fb4..810c285 100644 --- a/backend/agent/servers/mcp_server_web_search.py +++ b/backend/agent/servers/mcp_server_web_search.py @@ -1,3 +1,13 @@ +"""MCP server that provides web search and page-fetching tools. + +Exposes two MCP tools: + - web_search — keyword search via DuckDuckGo, returns titles, URLs, snippets + - fetch_page — fetch and extract readable text from a URL + +All outbound requests are guarded by _validate_url() which blocks non-HTTP +schemes and private/loopback IP ranges to prevent SSRF vulnerabilities. +""" + from urllib.parse import urlparse from mcp.server.fastmcp import FastMCP @@ -12,7 +22,22 @@ mcp = FastMCP("WebSearchServer") # ── Helper: URL validation (SSRF prevention) ───────────────────────────────── def _validate_url(url: str) -> str: - """Validate a URL to prevent SSRF attacks.""" + """Validate a URL and raise ValueError if it could be used for an SSRF attack. + + Blocks: + - Non-HTTP(S) schemes (file://, ftp://, etc.) + - Loopback and metadata addresses (localhost, 127.0.0.1, 169.254.169.254) + - RFC-1918 private IP ranges (10.x, 172.16-31.x, 192.168.x) + + Args: + url: The URL string to validate. + + Returns: + The original URL string unchanged if it passes all checks. + + Raises: + ValueError: If the URL fails any of the security checks. + """ parsed = urlparse(url) if parsed.scheme not in ("http", "https"): @@ -22,10 +47,13 @@ def _validate_url(url: str) -> str: hostname = parsed.hostname or "" + # Block well-known loopback and cloud-metadata addresses. blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"} if hostname in blocked_hosts: raise ValueError(f"Blocked internal host: {hostname}") + # Block all private RFC-1918 ranges by checking the string prefix. + # This is a best-effort check; a full implementation would resolve DNS first. private_prefixes = ( "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", @@ -100,6 +128,7 @@ def fetch_page(url: str) -> str: soup = BeautifulSoup(response.text, "html.parser") + # Remove boilerplate elements that add noise without informational value. for tag in soup(["script", "style", "nav", "footer"]): tag.decompose() diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index b4a449a..caeb603 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -1,3 +1,10 @@ +"""Executes code files from the editor in isolated subprocesses. + +Supports Python (.py) via the system Python interpreter and LaTeX (.tex) via +pdflatex. All execution is time-bounded by RUN_TIMEOUT to prevent runaway +processes from blocking the UI indefinitely. +""" + import subprocess from pathlib import Path diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index add29f3..62360b0 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -12,6 +12,15 @@ WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) class FileManager: + """Manages all file and folder operations inside the workspace directory. + + Every public method resolves the given path and verifies that the result + stays within ``base_path`` before touching the filesystem. This prevents + path-traversal attacks where a caller might pass ``../../etc/passwd``. + + The workspace directory is created on first use if it does not yet exist. + """ + def __init__(self, base_path=Path("workspace")) -> None: self.base_path = Path(base_path) self.base_path.mkdir(exist_ok=True) diff --git a/frontend/chat.py b/frontend/chat.py index 5db855e..af7e236 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -10,10 +10,23 @@ from backend.managers.system_prompter import SystemPrompter # ── Agent Mode helpers ──────────────────────────────────────────────────────── def _run_async(coro): - """Hilfsfunktion um async Code in sync Streamlit auszuführen""" + """Execute an async coroutine from synchronous Streamlit code. + + Streamlit runs in a synchronous context, but the CodingAgent uses async + methods (for MCP tool calls). This helper bridges the gap by reusing an + already-running event loop when one exists, or creating a new one otherwise. + + Args: + coro: The coroutine to run. + + Returns: + The return value of the coroutine. + """ try: + # Reuse the loop that is already running (e.g. inside pytest-asyncio). loop = asyncio.get_running_loop() except RuntimeError: + # No running loop in this thread — create a fresh one. loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop.run_until_complete(coro) diff --git a/frontend/editor.py b/frontend/editor.py index 805e1d7..19fe6b9 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -62,6 +62,14 @@ def _rename_dialog(file_path: str): @st.dialog("Delete File") def _delete_dialog(abs_file_path: str): + """Confirmation dialog before permanently deleting the given file. + + Removes the file from disk and also cleans up the editor's open-files list, + content cache, and active_file pointer so the UI stays consistent. + + Args: + abs_file_path: Absolute path to the file that should be deleted. + """ fm = FileManager() file_name = Path(abs_file_path).name relative_path = str(Path(abs_file_path).relative_to(fm.base_path)) diff --git a/frontend/sidebar.py b/frontend/sidebar.py index f496190..4ee3db7 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -53,6 +53,12 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str): @st.dialog("Add File") def _add_file_dialog(parent_path: str = ""): + """Dialog for creating a new file inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the file at the workspace root. + """ with st.form("add_file_form"): name = st.text_input("File name:", placeholder="e.g. script.py") col1, col2 = st.columns(2) @@ -77,6 +83,12 @@ def _add_file_dialog(parent_path: str = ""): @st.dialog("Add Folder") def _add_folder_dialog(parent_path: str = ""): + """Dialog for creating a new subfolder inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the folder at the workspace root. + """ with st.form("add_folder_form"): name = st.text_input("Folder name:", placeholder="e.g. utils") col1, col2 = st.columns(2) @@ -166,6 +178,15 @@ def _rename_file_dialog(relative_file_path: str, file_name: str): @st.dialog("Delete File") def _delete_file_dialog(relative_file_path: str, file_name: str): + """Confirmation dialog before permanently deleting a file. + + After a successful delete the file is also removed from the editor's + open-files list and content cache so it cannot be saved back to disk. + + Args: + relative_file_path: Workspace-relative path to the file (used by FileManager). + file_name: Display name shown in the warning message. + """ st.warning(f"Delete **{file_name}**? This cannot be undone.") col1, col2 = st.columns(2) @@ -175,6 +196,7 @@ def _delete_file_dialog(relative_file_path: str, file_name: str): abs_file_path = str(Path(fm.base_path) / relative_file_path) st.session_state.open_files.remove(abs_file_path) st.session_state.files_content.pop(abs_file_path, None) + # Fall back to the first remaining open file, or None if all tabs are closed. if st.session_state.active_file == abs_file_path: st.session_state.active_file = ( st.session_state.open_files[0] diff --git a/frontend/state.py b/frontend/state.py index b5c7b7b..d7e5f19 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -10,45 +10,52 @@ from backend.managers.chat_manager import ChatManager def init_state(): - # Sidebar state initialization + """Initialise all Streamlit session-state keys with safe defaults. + + Uses ``if key not in st.session_state`` guards throughout so that existing + values are never overwritten on subsequent reruns — only missing keys are + set. This means it is safe to call multiple times per session. + """ + # ── Sidebar state ───────────────────────────────────────────────────────── + # last_selected tracks the previously clicked tree node to detect new clicks + # and avoid re-running the same file-open logic on every Streamlit rerender. if "last_selected" not in st.session_state: st.session_state.last_selected = None - # Absolute path and workspace-relative path of the currently highlighted folder + # Absolute path and workspace-relative path of the currently highlighted folder. + # Both are set together; both are cleared together when a folder is deselected. if "selected_folder" not in st.session_state: st.session_state.selected_folder = None if "selected_folder_rel" not in st.session_state: st.session_state.selected_folder_rel = None - # Chat manager (persists across reruns) - # ChatManager keeps the full conversation history in memory across reruns + # ── Chat manager ────────────────────────────────────────────────────────── + + # ChatManager keeps the full conversation history in memory across reruns. + # Instantiated once and reused so history is not lost on page rerenders. if "chat_manager" not in st.session_state: st.session_state.chat_manager = ChatManager() - # Editor state initialization - # List of absolute file paths that are currently open as tabs + # ── Editor state ────────────────────────────────────────────────────────── + + # Ordered list of absolute file paths currently open as editor tabs. + # The list order determines the visual tab order in the UI. if "open_files" not in st.session_state: - """A list of currently open file paths - absolute paths only. The order determines the tab order in the UI. - Format: [ "path/to/file1.py", "path/to/file2.js", ... ] - """ st.session_state.open_files = [] - # Dict mapping file path → current editor content (may be unsaved) + # Dict mapping absolute file path → current editor content (may differ from + # disk if the user has unsaved changes). if "files_content" not in st.session_state: - """A dictionary mapping file paths to their current content in the editor. - Format: { "path/to/file.py": "file content as string", ... } - """ st.session_state.files_content = {} - # Absolute path of the file whose tab is currently active + # Absolute path of the file whose tab is currently active in the editor. + # Must always be one of the paths in open_files, or None if no file is open. if "active_file" not in st.session_state: - """The currently active file in the editor (absolute path in string e.g. "/workspace/path/to/file.py"). - Should be one of the paths in open_files or None if no file is open.""" st.session_state.active_file = None - # Index of the active tab (used by st.tabs) + # Index of the active tab — kept in sync with active_file for st.tabs(). if "active_tab" not in st.session_state: st.session_state.active_tab = 0 @@ -58,33 +65,41 @@ def init_state(): if "code_suggestions" not in st.session_state: st.session_state.code_suggestions = [] - # Output dict from the last code run: {stdout, stderr, return_code} + # Output dict from the last code execution: {stdout, stderr, return_code}. + # Initialised as empty string so the editor view can safely check falsyness. if "code_execution_output" not in st.session_state: st.session_state.code_execution_output = "" - # Chat state initialization - # Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles + # ── Chat state ──────────────────────────────────────────────────────────── + + # Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles. + # System messages are stored here too but skipped during display. if "chat_history" not in st.session_state: st.session_state.chat_history = [] - # Agent Mode state - # Whether the UI is currently in Agent Mode (vs normal chat) + # ── Agent Mode state ────────────────────────────────────────────────────── + + # Boolean toggle — True while the UI is in Coding Agent mode. if "agent_mode" not in st.session_state: st.session_state.agent_mode = False - # The live CodingAgent instance while a task is running + # The live CodingAgent instance while a task is running. + # Set by _start_agent(), cleared by _reset_agent(). if "coding_agent" not in st.session_state: st.session_state.coding_agent = None - # Current status of the agent: "idle" | "waiting_approval" | "done" + # Lifecycle state of the agent: "idle" | "waiting_approval" | "done". + # Controls which sub-screen render_agent_mode() displays. if "agent_status" not in st.session_state: st.session_state.agent_status = "idle" - # List of completed steps shown in the collapsible Agent Log + # Chronological list of completed step records shown in the Agent Log expander. + # Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str} if "agent_log" not in st.session_state: st.session_state.agent_log = [] - # The action the agent proposed but has not yet been approved or rejected + # The action the agent has proposed but that has not yet been approved or + # rejected by the user. Stored as the raw dict returned by propose_next_action(). if "agent_pending_action" not in st.session_state: st.session_state.agent_pending_action = None