From 3daea36ba9c2f482c46a999bdbd318bf9665b80d Mon Sep 17 00:00:00 2001 From: Javier Martinez Date: Wed, 10 Jun 2026 21:27:12 +0800 Subject: [PATCH] feat: use sandbox inside of code executor --- .../code_execution/bash_executor.py | 2 +- .../code_execution_component.py | 4 +- .../components/code_execution/local.py | 306 ++++-------------- .../components/code_execution/mount.py | 75 ----- .../code_execution/sandbox_session.py | 149 +++++++++ private_gpt/components/sandbox/base.py | 87 ++++- private_gpt/components/sandbox/local_async.py | 130 ++++++++ private_gpt/components/sandbox/mount.py | 74 +++++ private_gpt/components/sandbox/registry.py | 19 +- .../components/sandbox/sandbox_component.py | 28 +- .../tools/builders/bash_tool_builder.py | 6 +- private_gpt/launcher.py | 4 + .../server/chat/chat_request_mapper.py | 5 +- private_gpt/settings/settings.py | 77 ++--- pyproject.toml | 1 + settings.yaml | 9 +- 16 files changed, 585 insertions(+), 391 deletions(-) delete mode 100644 private_gpt/components/code_execution/mount.py create mode 100644 private_gpt/components/code_execution/sandbox_session.py create mode 100644 private_gpt/components/sandbox/local_async.py create mode 100644 private_gpt/components/sandbox/mount.py diff --git a/private_gpt/components/code_execution/bash_executor.py b/private_gpt/components/code_execution/bash_executor.py index da2c94bc..366ad399 100644 --- a/private_gpt/components/code_execution/bash_executor.py +++ b/private_gpt/components/code_execution/bash_executor.py @@ -37,7 +37,7 @@ def _cap_bytes(data: bytes, limit: int) -> str: return data.decode("utf-8", errors="replace") -class BashExecutor: +class LocalBashExecutor: """Async subprocess executor with resource isolation (Unix) and output capping. On Unix: uses os.setsid + setrlimit (CPU, virtual memory, file size, nproc). diff --git a/private_gpt/components/code_execution/code_execution_component.py b/private_gpt/components/code_execution/code_execution_component.py index ed8c0113..c24ad7d7 100644 --- a/private_gpt/components/code_execution/code_execution_component.py +++ b/private_gpt/components/code_execution/code_execution_component.py @@ -6,12 +6,12 @@ from typing import TYPE_CHECKING from injector import inject, singleton from private_gpt.components.code_execution.registry import CodeExecutionProviderRegistry -from private_gpt.settings.settings import Settings # noqa: TC001 +from private_gpt.settings.settings import Settings if TYPE_CHECKING: from private_gpt.components.code_execution.base import ( CodeExecutionProvider, - CodeExecutionSession, + CodeExecutionSession, # noqa: TC004 ) from private_gpt.components.code_execution.content_bundle import ContentBundle diff --git a/private_gpt/components/code_execution/local.py b/private_gpt/components/code_execution/local.py index 38df5958..bfdfa81f 100644 --- a/private_gpt/components/code_execution/local.py +++ b/private_gpt/components/code_execution/local.py @@ -2,259 +2,62 @@ from __future__ import annotations import asyncio import hashlib -import shutil import time from pathlib import Path from typing import TYPE_CHECKING -import anyio from injector import inject from private_gpt.components.code_execution.base import ( - BashExecutionResult, CodeExecutionProvider, - CodeExecutionSession, - FileOperationResult, ) -from private_gpt.components.code_execution.bash_executor import BashExecutor -from private_gpt.components.code_execution.mount import LocalMount, ReadOnlyMount -from private_gpt.components.code_execution.path_translator import PathTranslator -from private_gpt.settings.settings import Settings # noqa: TC001 +from private_gpt.components.code_execution.bash_executor import LocalBashExecutor +from private_gpt.components.code_execution.sandbox_session import ( + SandboxCodeExecutionSession, +) +from private_gpt.components.sandbox.local_async import BashExecutorSandbox +from private_gpt.components.sandbox.mount import ( + LocalMount, + LocalMountSpec, + ReadOnlyMount, +) +from private_gpt.settings.settings import Settings if TYPE_CHECKING: + from private_gpt.components.code_execution.base import CodeExecutionSession from private_gpt.components.code_execution.content_bundle import ContentBundle - from private_gpt.components.code_execution.mount import SessionMount + from private_gpt.components.sandbox.mount import SessionMount def _cache_key(canonical_path: str) -> str: - """Derive a short, filesystem-safe cache directory name from a canonical path.""" return hashlib.sha1(canonical_path.encode()).hexdigest()[:16] -class LocalCodeExecutionSession(CodeExecutionSession): - """Code execution session backed by the local filesystem. - - All path operations go through PathTranslator: - /home/agent/ → {base}/sessions/{id}/workspace/ (writable) - /mnt/user-data/ → {base}/sessions/{id}/uploads|outputs/ - /mnt/skills/... → {base}/content_cache/{hash}/ (read-only) - - Falls back to workspace-relative resolution for bare/relative paths so - that existing callers remain compatible. - """ - - def __init__( - self, - session_id: str, - translator: PathTranslator, - workspace: Path, - last_accessed: list[float], - executor: BashExecutor, - ) -> None: - self._id = session_id - self._translator = translator - self._workspace = workspace - self._last_accessed = last_accessed # mutable cell shared with provider - self._executor = executor - - def _touch(self) -> None: - self._last_accessed[0] = time.monotonic() - - def _resolve(self, path: str) -> Path: - try: - return self._translator.to_real(path) - except ValueError: - cleaned = path.lstrip("/") - candidate = (self._workspace / cleaned).resolve() - if ( - candidate != self._workspace - and self._workspace not in candidate.parents - ): - raise ValueError( - f"Path '{path}' escapes the session workspace." - ) from None - return candidate - - async def execute_bash( - self, command: str, timeout: int | None = None, restart: bool = False - ) -> BashExecutionResult: - self._touch() - if restart: - await anyio.to_thread.run_sync( - lambda: shutil.rmtree(self._workspace, ignore_errors=True) - ) - await anyio.to_thread.run_sync( - lambda: self._workspace.mkdir(parents=True, exist_ok=True) - ) - translated = self._translator.rewrite_command(command) - result = await self._executor.run( - translated, cwd=self._workspace, timeout=timeout - ) - return BashExecutionResult( - success=result.success, - stdout=self._translator.scrub_output(result.stdout), - stderr=self._translator.scrub_output(result.stderr), - exit_code=result.exit_code, - execution_time_ms=result.execution_time_ms, - ) - - async def view( - self, path: str, view_range: tuple[int, int] | None = None - ) -> FileOperationResult: - self._touch() - try: - target = self._resolve(path) - if not await anyio.to_thread.run_sync(target.exists): - return FileOperationResult( - success=False, error=f"File not found: {path}" - ) - - if await anyio.to_thread.run_sync(target.is_dir): - entries = await anyio.to_thread.run_sync( - lambda: [ - (entry.name, entry.is_dir()) - for entry in sorted(target.iterdir(), key=lambda e: e.name) - ] - ) - lines = [ - f"[dir] {name}" if is_dir else f"[file] {name}" - for name, is_dir in entries - ] - return FileOperationResult(success=True, output="\n".join(lines)) - - text = await anyio.to_thread.run_sync( - lambda: target.read_text(encoding="utf-8") - ) - lines = text.splitlines() - base_line = 1 - if view_range is not None: - start, end = view_range - start_index = max(start, 1) - 1 - end_index = None if end == -1 else max(end, 0) - lines = lines[start_index:end_index] - base_line = start_index + 1 - output = "\n".join( - f"{i}: {line}" for i, line in enumerate(lines, start=base_line) - ) - return FileOperationResult(success=True, output=output) - except Exception as exc: - return FileOperationResult(success=False, error=str(exc)) - - async def str_replace( - self, path: str, old_str: str, new_str: str - ) -> FileOperationResult: - self._touch() - try: - self._translator.assert_writable(path) - target = self._resolve(path) - if not await anyio.to_thread.run_sync(target.exists): - return FileOperationResult( - success=False, error=f"File not found: {path}" - ) - text = await anyio.to_thread.run_sync( - lambda: target.read_text(encoding="utf-8") - ) - occurrences = text.count(old_str) - if occurrences == 0: - return FileOperationResult( - success=False, error="old_str was not found in the file." - ) - if occurrences > 1: - return FileOperationResult( - success=False, error="old_str appears more than once in the file." - ) - updated = text.replace(old_str, new_str, 1) - await anyio.to_thread.run_sync( - lambda: target.write_text(updated, encoding="utf-8") - ) - return FileOperationResult(success=True, output=f"Updated {path}") - except Exception as exc: - return FileOperationResult(success=False, error=str(exc)) - - async def create(self, path: str, file_text: str) -> FileOperationResult: - self._touch() - try: - self._translator.assert_writable(path) - target = self._resolve(path) - if await anyio.to_thread.run_sync(target.exists): - return FileOperationResult( - success=False, error=f"File already exists: {path}" - ) - await anyio.to_thread.run_sync( - lambda: target.parent.mkdir(parents=True, exist_ok=True) - ) - await anyio.to_thread.run_sync( - lambda: target.write_text(file_text, encoding="utf-8") - ) - return FileOperationResult(success=True, output=f"Created {path}") - except Exception as exc: - return FileOperationResult(success=False, error=str(exc)) - - async def insert( - self, path: str, insert_line: int, new_str: str - ) -> FileOperationResult: - self._touch() - try: - self._translator.assert_writable(path) - target = self._resolve(path) - if not await anyio.to_thread.run_sync(target.exists): - return FileOperationResult( - success=False, error=f"File not found: {path}" - ) - text = await anyio.to_thread.run_sync( - lambda: target.read_text(encoding="utf-8") - ) - lines = text.splitlines() - if insert_line < 0 or insert_line > len(lines): - return FileOperationResult( - success=False, - error=f"insert_line {insert_line} is out of range.", - ) - insertion = new_str.splitlines() - updated_lines = lines[:insert_line] + insertion + lines[insert_line:] - updated = "\n".join(updated_lines) - if text.endswith("\n") or new_str.endswith("\n"): - updated += "\n" - await anyio.to_thread.run_sync( - lambda: target.write_text(updated, encoding="utf-8") - ) - return FileOperationResult(success=True, output=f"Updated {path}") - except Exception as exc: - return FileOperationResult(success=False, error=str(exc)) - - async def close(self) -> None: - pass # Provider owns lifecycle; disk files preserved for kernel-restart recovery - - class LocalCodeExecutionProvider(CodeExecutionProvider): - """Provides LocalCodeExecutionSession instances backed by the local filesystem. + """Provides SandboxCodeExecutionSession instances backed by the local filesystem. - Session directories survive kernel restarts — if a session directory already - exists on disk when create_session() is called, the existing files are reused. - - Skills and other ContentBundles are materialised to a shared content cache so - that the same version is not re-downloaded for every session. + Uses BashExecutorSandbox as the execution backend. Session directories survive + kernel restarts — existing files are reused on reconnect. """ @inject def __init__(self, settings: Settings) -> None: super().__init__(settings) - ce = settings.code_execution base = Path( - ce.workspace_path + settings.code_execution.workspace_path or Path(settings.data.local_data_folder) / "code_execution_workspaces" ) self._sessions_dir = base / "sessions" - self._content_cache = Path(ce.skills_cache_path or base / "content_cache") - self._executor = BashExecutor( - cpu_limit_seconds=ce.bash_cpu_limit_seconds, - memory_limit_mb=ce.bash_memory_limit_mb, - fsize_limit_mb=ce.bash_fsize_limit_mb, - nproc_limit=ce.bash_nproc_limit, - output_cap_bytes=ce.bash_output_cap_bytes, + self._content_cache = Path(base / "content_cache") + self._executor = LocalBashExecutor( + cpu_limit_seconds=settings.bash.cpu_limit_seconds, + memory_limit_mb=settings.bash.memory_limit_mb, + fsize_limit_mb=settings.bash.fsize_limit_mb, + nproc_limit=settings.bash.nproc_limit, + output_cap_bytes=settings.bash.output_cap_bytes, ) - self._ttl = ce.session_ttl_seconds - self._active: dict[str, tuple[LocalCodeExecutionSession, list[float]]] = {} + self._ttl = settings.code_execution.session_ttl_seconds + self._active: dict[str, tuple[SandboxCodeExecutionSession, list[float]]] = {} self._lock = asyncio.Lock() self._reaper_started = False @@ -262,34 +65,49 @@ class LocalCodeExecutionProvider(CodeExecutionProvider): self, session_id: str, extra_bundles: list[ContentBundle] | None = None, - ) -> LocalCodeExecutionSession: + ) -> SandboxCodeExecutionSession: session_dir = self._sessions_dir / session_id - mounts: list[SessionMount] = [ - LocalMount("/home/agent/", session_dir / "workspace", writable=True), - LocalMount( - "/mnt/user-data/uploads/", session_dir / "uploads", writable=False + # Build LocalMountSpec list — typed pydantic models + local_specs: list[LocalMountSpec] = [ + LocalMountSpec( + canonical="/home/agent/", + real_path=session_dir / "workspace", + writable=True, ), - LocalMount( - "/mnt/user-data/outputs/", session_dir / "outputs", writable=True + LocalMountSpec( + canonical="/mnt/user-data/uploads/", + real_path=session_dir / "uploads", + writable=False, + ), + LocalMountSpec( + canonical="/mnt/user-data/outputs/", + real_path=session_dir / "outputs", + writable=True, ), ] - for bundle in extra_bundles or []: cache_dir = self._content_cache / _cache_key(bundle.canonical_path) - mounts.append(ReadOnlyMount(bundle.canonical_path, bundle.files, cache_dir)) + local_specs.append( + LocalMountSpec( + canonical=bundle.canonical_path, + real_path=cache_dir, + writable=bundle.writable, + ) + ) + + sandbox = BashExecutorSandbox(local_specs, self._executor) + + # Build SessionMount list — delegates setup to sandbox APIs + mounts: list[SessionMount] = [LocalMount(spec) for spec in local_specs[:3]] + for bundle, spec in zip(extra_bundles or [], local_specs[3:], strict=True): + mounts.append(ReadOnlyMount(spec, bundle.files)) + + await asyncio.gather(*[m.prepare(sandbox) for m in mounts]) - real_paths = await asyncio.gather(*[m.prepare() for m in mounts]) - translator = PathTranslator( - [ - (m.canonical, rp, m.writable) - for m, rp in zip(mounts, real_paths, strict=True) - ] - ) - workspace = real_paths[0] last_accessed: list[float] = [time.monotonic()] - session = LocalCodeExecutionSession( - session_id, translator, workspace, last_accessed, self._executor + session = SandboxCodeExecutionSession( + session_id, sandbox, "/home/agent/", last_accessed ) async with self._lock: @@ -299,11 +117,9 @@ class LocalCodeExecutionProvider(CodeExecutionProvider): return session def delete_session(self, session: CodeExecutionSession) -> None: - if isinstance(session, LocalCodeExecutionSession): + if isinstance(session, SandboxCodeExecutionSession): sid = session._id self._active.pop(sid, None) - # Disk files preserved — next create_session() with the same session_id - # will reuse the existing workspace directory. def _ensure_reaper(self) -> None: if not self._reaper_started: diff --git a/private_gpt/components/code_execution/mount.py b/private_gpt/components/code_execution/mount.py deleted file mode 100644 index f64c5058..00000000 --- a/private_gpt/components/code_execution/mount.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pathlib import Path - - from private_gpt.components.code_execution.content_bundle import BundledFile - - -class SessionMount(ABC): - """One mount point for a local session. - - prepare() is awaited concurrently at session creation and returns the real - Path on the local filesystem. The PathTranslator is then built from the - resolved paths. - - Future local-filesystem backends (s3fs, tmpfs, etc.) implement this interface - without touching the session or provider code. - """ - - canonical: str - writable: bool - - @abstractmethod - async def prepare(self) -> Path: - """Initialize the mount and return its real filesystem path.""" - - async def teardown(self) -> None: # noqa: B027 - """Optional cleanup. Default is a no-op.""" - - -class LocalMount(SessionMount): - """Simple writable-or-read-only local directory.""" - - def __init__(self, canonical: str, path: Path, *, writable: bool) -> None: - self.canonical = canonical - self.writable = writable - self._path = path - - async def prepare(self) -> Path: - self._path.mkdir(parents=True, exist_ok=True) - return self._path - - -class ReadOnlyMount(SessionMount): - """Generic read-only mount: materialises pre-fetched file content to a local dir. - - Works for any ContentBundle — skills, plugins, or future content types. - The cache directory is shared across sessions: if it already exists (from a - previous session), files are not re-written. - """ - - writable: bool = False - - def __init__( - self, - canonical: str, - files: list[BundledFile], - cache_dir: Path, - ) -> None: - self.canonical = canonical - self.writable = False - self._files = files - self._cache_dir = cache_dir - - async def prepare(self) -> Path: - if not self._cache_dir.exists(): - for file in self._files: - target = self._cache_dir / file.path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(file.content) - target.chmod(file.permissions) - return self._cache_dir diff --git a/private_gpt/components/code_execution/sandbox_session.py b/private_gpt/components/code_execution/sandbox_session.py new file mode 100644 index 00000000..8b5b6e64 --- /dev/null +++ b/private_gpt/components/code_execution/sandbox_session.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from private_gpt.components.code_execution.base import ( + BashExecutionResult, + CodeExecutionSession, + FileOperationResult, +) +from private_gpt.components.sandbox.base import SandboxExecOptions + +if TYPE_CHECKING: + from private_gpt.components.sandbox.base import AsyncSandboxSession + + +class SandboxCodeExecutionSession(CodeExecutionSession): + """Generic CodeExecutionSession backed by any AsyncSandboxSession. + + The sandbox is responsible for path translation and permission enforcement. + This class only adds the CodeExecutionSession protocol on top. + """ + + def __init__( + self, + session_id: str, + sandbox: AsyncSandboxSession, + workspace_canonical: str, + last_accessed: list[float], + ) -> None: + self._id = session_id + self._sandbox = sandbox + self._workspace = workspace_canonical + self._last_accessed = last_accessed + + def _touch(self) -> None: + self._last_accessed[0] = time.monotonic() + + async def execute_bash( + self, command: str, timeout: int | None = None, restart: bool = False + ) -> BashExecutionResult: + self._touch() + if restart: + await self._sandbox.exec( + f"rm -rf {self._workspace}* {self._workspace}.[!.]*", + SandboxExecOptions(cwd="/"), + ) + await self._sandbox.make_dir(self._workspace) + result = await self._sandbox.exec( + command, + SandboxExecOptions(timeout=timeout, cwd=self._workspace), + ) + return BashExecutionResult( + success=result.success, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + execution_time_ms=result.execution_time_ms, + ) + + async def view( + self, path: str, view_range: tuple[int, int] | None = None + ) -> FileOperationResult: + self._touch() + try: + if not await self._sandbox.path_exists(path): + return FileOperationResult( + success=False, error=f"File not found: {path}" + ) + if await self._sandbox.is_dir(path): + entries = await self._sandbox.list_dir(path) + return FileOperationResult(success=True, output="\n".join(entries)) + raw = await self._sandbox.read_file(path) + text = raw.decode("utf-8", errors="replace") + lines = text.splitlines() + base_line = 1 + if view_range is not None: + start, end = view_range + start_idx = max(start, 1) - 1 + end_idx = None if end == -1 else max(end, 0) + lines = lines[start_idx:end_idx] + base_line = start_idx + 1 + output = "\n".join( + f"{i}: {line}" for i, line in enumerate(lines, start=base_line) + ) + return FileOperationResult(success=True, output=output) + except Exception as exc: + return FileOperationResult(success=False, error=str(exc)) + + async def str_replace( + self, path: str, old_str: str, new_str: str + ) -> FileOperationResult: + self._touch() + try: + raw = await self._sandbox.read_file(path) + text = raw.decode("utf-8", errors="replace") + occurrences = text.count(old_str) + if occurrences == 0: + return FileOperationResult( + success=False, error="old_str was not found in the file." + ) + if occurrences > 1: + return FileOperationResult( + success=False, + error="old_str appears more than once in the file.", + ) + updated = text.replace(old_str, new_str, 1) + await self._sandbox.write_file(path, updated.encode("utf-8")) + return FileOperationResult(success=True, output=f"Updated {path}") + except Exception as exc: + return FileOperationResult(success=False, error=str(exc)) + + async def create(self, path: str, file_text: str) -> FileOperationResult: + self._touch() + try: + if await self._sandbox.path_exists(path): + return FileOperationResult( + success=False, error=f"File already exists: {path}" + ) + await self._sandbox.write_file(path, file_text.encode("utf-8")) + return FileOperationResult(success=True, output=f"Created {path}") + except Exception as exc: + return FileOperationResult(success=False, error=str(exc)) + + async def insert( + self, path: str, insert_line: int, new_str: str + ) -> FileOperationResult: + self._touch() + try: + raw = await self._sandbox.read_file(path) + text = raw.decode("utf-8", errors="replace") + lines = text.splitlines() + if insert_line < 0 or insert_line > len(lines): + return FileOperationResult( + success=False, + error=f"insert_line {insert_line} is out of range.", + ) + insertion = new_str.splitlines() + updated_lines = lines[:insert_line] + insertion + lines[insert_line:] + updated = "\n".join(updated_lines) + if text.endswith("\n") or new_str.endswith("\n"): + updated += "\n" + await self._sandbox.write_file(path, updated.encode("utf-8")) + return FileOperationResult(success=True, output=f"Updated {path}") + except Exception as exc: + return FileOperationResult(success=False, error=str(exc)) + + async def close(self) -> None: + await self._sandbox.close() diff --git a/private_gpt/components/sandbox/base.py b/private_gpt/components/sandbox/base.py index aa617e3a..e7da3335 100644 --- a/private_gpt/components/sandbox/base.py +++ b/private_gpt/components/sandbox/base.py @@ -1,11 +1,17 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field from private_gpt.settings.settings import Settings +if TYPE_CHECKING: + from private_gpt.components.code_execution.content_bundle import BundledFile + from private_gpt.components.sandbox.mount import SandboxMountSpec + class SandboxExecutionResult(BaseModel): """Result from a sandbox command or code execution.""" @@ -68,7 +74,7 @@ class SandboxSession(ABC): def install_package(self, package_name: str) -> SandboxExecutionResult: return self.exec(f"python -m pip install {package_name}") - def __enter__(self) -> "SandboxSession": + def __enter__(self) -> SandboxSession: self.start() return self @@ -92,3 +98,80 @@ class SandboxProvider(ABC): SandboxProviderFactory = type[SandboxProvider] | Callable[[Settings], SandboxProvider] + + +class AsyncSandboxProvider(ABC): + def __init__(self, settings: Settings) -> None: + self.settings = settings + + @abstractmethod + async def create_session( + self, + user_id: str | None = None, + timeout: int | None = None, + bundle_specs: list[SandboxMountSpec] | None = None, + ) -> AsyncSandboxSession: + """Create a sandbox session. The session may be lazy until start().""" + + @abstractmethod + async def delete_session(self, session: AsyncSandboxSession) -> None: + """Delete a sandbox session and release all associated resources.""" + + +AsyncSandboxProviderFactory = ( + type[AsyncSandboxProvider] | Callable[[Settings], AsyncSandboxProvider] +) + + +class AsyncSandboxSession(ABC): + """Async sandbox session with exec + file operations. + + Permission enforcement: write_file() and chmod() check if the path is + in a writable mount. + initialize_mount() bypasses this check — for session setup only. + make_dir() does not check writable. + """ + + @abstractmethod + async def exec( + self, command: str, opts: SandboxExecOptions | None = None + ) -> SandboxExecutionResult: + """Execute a shell command.""" + + @abstractmethod + async def read_file(self, path: str) -> bytes: + """Read file content.""" + + @abstractmethod + async def write_file(self, path: str, content: bytes) -> None: + """Write file content. Raises ValueError if path is in a read-only mount.""" + + @abstractmethod + async def path_exists(self, path: str) -> bool: + """Return True if the path exists.""" + + @abstractmethod + async def is_dir(self, path: str) -> bool: + """Return True if the path is a directory.""" + + @abstractmethod + async def list_dir(self, path: str) -> list[str]: + """List directory contents as '[dir] name' / '[file] name' strings.""" + + @abstractmethod + async def make_dir( + self, path: str, *, parents: bool = True, exist_ok: bool = True + ) -> None: + """Create directory. Does not check writable.""" + + @abstractmethod + async def chmod(self, path: str, mode: int) -> None: + """Set file permissions. Raises ValueError if path is in a read-only mount.""" + + @abstractmethod + async def initialize_mount(self, canonical: str, files: list[BundledFile]) -> None: + """Write mount files during session setup. Bypasses writable check.""" + + @abstractmethod + async def close(self) -> None: + """Release resources.""" diff --git a/private_gpt/components/sandbox/local_async.py b/private_gpt/components/sandbox/local_async.py new file mode 100644 index 00000000..45bc73d7 --- /dev/null +++ b/private_gpt/components/sandbox/local_async.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import anyio +import anyio.to_thread + +from private_gpt.components.code_execution.path_translator import ( + PathTranslator, +) +from private_gpt.components.sandbox.base import ( + AsyncSandboxSession, + SandboxExecutionResult, +) + +if TYPE_CHECKING: + from pathlib import Path + + from private_gpt.components.code_execution.bash_executor import ( + LocalBashExecutor, + ) + from private_gpt.components.code_execution.content_bundle import BundledFile + from private_gpt.components.sandbox.base import ( + SandboxExecOptions, + ) + from private_gpt.components.sandbox.mount import LocalMountSpec + + +class BashExecutorSandbox(AsyncSandboxSession): + """Local async sandbox: BashExecutor for exec, pathlib for file ops. + + Translates canonical paths → real local paths via PathTranslator. + Enforces read-only constraints on write_file() and chmod(). + initialize_mount() bypasses the check for session setup. + """ + + def __init__( + self, mounts: list[LocalMountSpec], executor: LocalBashExecutor + ) -> None: + self._translator = PathTranslator( + [(m.canonical, m.real_path, m.writable) for m in mounts] + ) + self._executor = executor + self._readonly = [m.canonical for m in mounts if not m.writable] + + def _assert_writable(self, path: str) -> None: + for prefix in self._readonly: + if path.startswith(prefix): + raise ValueError(f"Path '{path}' is in a read-only mount ('{prefix}').") + + async def exec( + self, command: str, opts: SandboxExecOptions | None = None + ) -> SandboxExecutionResult: + cwd = self._translator.to_real((opts.cwd if opts else None) or "/home/agent/") + cmd = self._translator.rewrite_command(command) + result = await self._executor.run( + cmd, cwd=cwd, timeout=opts.timeout if opts else None + ) + return SandboxExecutionResult( + success=result.success, + stdout=self._translator.scrub_output(result.stdout), + stderr=self._translator.scrub_output(result.stderr), + exit_code=result.exit_code, + execution_time_ms=result.execution_time_ms, + ) + + async def read_file(self, path: str) -> bytes: + real = self._translator.to_real(path) + return await anyio.to_thread.run_sync(real.read_bytes) + + async def write_file(self, path: str, content: bytes) -> None: + self._assert_writable(path) + real = self._translator.to_real(path) + await anyio.to_thread.run_sync( + lambda: real.parent.mkdir(parents=True, exist_ok=True) + ) + await anyio.to_thread.run_sync(lambda: real.write_bytes(content)) + + async def path_exists(self, path: str) -> bool: + real = self._translator.to_real(path) + return await anyio.to_thread.run_sync(real.exists) + + async def is_dir(self, path: str) -> bool: + real = self._translator.to_real(path) + return await anyio.to_thread.run_sync(real.is_dir) + + async def list_dir(self, path: str) -> list[str]: + real = self._translator.to_real(path) + entries: list[tuple[str, bool]] = await anyio.to_thread.run_sync( + lambda: [ + (e.name, e.is_dir()) + for e in sorted(real.iterdir(), key=lambda e: e.name) + ] + ) + return [f"[dir] {name}" if is_d else f"[file] {name}" for name, is_d in entries] + + async def make_dir( + self, path: str, *, parents: bool = True, exist_ok: bool = True + ) -> None: + real = self._translator.to_real(path) + await anyio.to_thread.run_sync( + lambda: real.mkdir(parents=parents, exist_ok=exist_ok) + ) + + async def chmod(self, path: str, mode: int) -> None: + self._assert_writable(path) + real = self._translator.to_real(path) + await anyio.to_thread.run_sync(lambda: real.chmod(mode)) + + async def initialize_mount(self, canonical: str, files: list[BundledFile]) -> None: + for f in files: + real = self._translator.to_real(canonical + f.path) + content = f.content + permissions = f.permissions + + def _mkdir(r: Path = real) -> None: + r.parent.mkdir(parents=True, exist_ok=True) + + def _write(r: Path = real, c: bytes = content) -> None: + r.write_bytes(c) + + def _chmod(r: Path = real, p: int = permissions) -> None: + r.chmod(p) + + await anyio.to_thread.run_sync(_mkdir) + await anyio.to_thread.run_sync(_write) + await anyio.to_thread.run_sync(_chmod) + + async def close(self) -> None: + pass diff --git a/private_gpt/components/sandbox/mount.py b/private_gpt/components/sandbox/mount.py new file mode 100644 index 00000000..294cd417 --- /dev/null +++ b/private_gpt/components/sandbox/mount.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from private_gpt.components.code_execution.content_bundle import BundledFile + from private_gpt.components.sandbox.base import AsyncSandboxSession + + +class SandboxMountSpec(BaseModel): + """Canonical mount point visible to the agent — backend-agnostic.""" + + canonical: str # e.g. "/home/agent/" — must end with "/" + writable: bool = True + + +class LocalMountSpec(SandboxMountSpec): + """Local-filesystem mount: adds the real path used by BashExecutorSandbox.""" + + real_path: Path + + +class SessionMount(ABC): + """Describes how to set up one mount point using sandbox APIs.""" + + @property + @abstractmethod + def spec(self) -> SandboxMountSpec: + ... + + @abstractmethod + async def prepare(self, sandbox: AsyncSandboxSession) -> None: + """Initialize this mount via sandbox APIs.""" + + async def teardown(self, sandbox: AsyncSandboxSession) -> None: # noqa: B027 + """Optional cleanup. Default is a no-op.""" + + +class LocalMount(SessionMount): + """Creates a directory via sandbox.make_dir().""" + + def __init__(self, spec: SandboxMountSpec) -> None: + self._spec = spec + + @property + def spec(self) -> SandboxMountSpec: + return self._spec + + async def prepare(self, sandbox: AsyncSandboxSession) -> None: + await sandbox.make_dir(self._spec.canonical) + + +class ReadOnlyMount(SessionMount): + """Materialises BundledFiles via sandbox.initialize_mount(). Idempotent. + + For local backends path_exists() checks the real cache directory; if it + already exists the files are not re-written. + """ + + def __init__(self, spec: SandboxMountSpec, files: list[BundledFile]) -> None: + self._spec = spec + self._files = files + + @property + def spec(self) -> SandboxMountSpec: + return self._spec + + async def prepare(self, sandbox: AsyncSandboxSession) -> None: + if not await sandbox.path_exists(self._spec.canonical): + await sandbox.initialize_mount(self._spec.canonical, self._files) diff --git a/private_gpt/components/sandbox/registry.py b/private_gpt/components/sandbox/registry.py index 1d187ba1..9bd6da50 100644 --- a/private_gpt/components/sandbox/registry.py +++ b/private_gpt/components/sandbox/registry.py @@ -1,20 +1,29 @@ -from private_gpt.components.sandbox.base import SandboxProvider, SandboxProviderFactory +from private_gpt.components.sandbox.base import ( + AsyncSandboxProvider, + AsyncSandboxProviderFactory, + SandboxProvider, + SandboxProviderFactory, +) from private_gpt.components.sandbox.local import LocalSandboxProvider from private_gpt.settings.settings import Settings -_PROVIDERS: dict[str, SandboxProviderFactory] = {"local": LocalSandboxProvider} +_PROVIDERS: dict[str, SandboxProviderFactory | AsyncSandboxProviderFactory] = { + "local": LocalSandboxProvider +} -def register_sandbox(name: str, provider: SandboxProviderFactory) -> None: +def register_sandbox( + name: str, provider: SandboxProviderFactory | AsyncSandboxProviderFactory +) -> None: _PROVIDERS[name] = provider class SandboxProviderRegistry: def __init__(self, settings: Settings) -> None: self._settings = settings - self._providers: dict[str, SandboxProvider] = {} + self._providers: dict[str, SandboxProvider | AsyncSandboxProvider] = {} - def get_provider(self, name: str) -> SandboxProvider: + def get_provider(self, name: str) -> SandboxProvider | AsyncSandboxProvider: provider = self._providers.get(name) if provider is not None: return provider diff --git a/private_gpt/components/sandbox/sandbox_component.py b/private_gpt/components/sandbox/sandbox_component.py index bcce0d2f..1ed52c73 100644 --- a/private_gpt/components/sandbox/sandbox_component.py +++ b/private_gpt/components/sandbox/sandbox_component.py @@ -1,6 +1,12 @@ from injector import inject, singleton -from private_gpt.components.sandbox.base import SandboxSession +from private_gpt.components.sandbox.base import ( + AsyncSandboxProvider, + AsyncSandboxSession, + SandboxProvider, + SandboxSession, +) +from private_gpt.components.sandbox.mount import SandboxMountSpec from private_gpt.components.sandbox.registry import SandboxProviderRegistry from private_gpt.settings.settings import Settings @@ -18,5 +24,25 @@ class SandboxComponent: provider_name = self._settings.sandbox.provider if provider_name is None: return None + provider = self._registry.get_provider(provider_name) + assert isinstance(provider, SandboxProvider) + return provider.create_session(user_id=user_id, timeout=timeout) + + async def acreate_session( + self, + user_id: str | None = None, + timeout: int | None = None, + bundle_specs: list[SandboxMountSpec] | None = None, + ) -> AsyncSandboxSession | None: + provider_name = self._settings.sandbox.provider + if provider_name is None: + return None + + provider = self._registry.get_provider(provider_name) + assert isinstance(provider, AsyncSandboxProvider) + + return await provider.create_session( + user_id=user_id, timeout=timeout, bundle_specs=bundle_specs + ) diff --git a/private_gpt/components/tools/builders/bash_tool_builder.py b/private_gpt/components/tools/builders/bash_tool_builder.py index 12ba606a..5d41de9e 100644 --- a/private_gpt/components/tools/builders/bash_tool_builder.py +++ b/private_gpt/components/tools/builders/bash_tool_builder.py @@ -6,15 +6,15 @@ from injector import inject, singleton from private_gpt.components.chat.models.chat_config_models import ToolSpec from private_gpt.components.code_execution.code_execution_component import ( - CodeExecutionComponent, # noqa: TC001 + CodeExecutionComponent, ) from private_gpt.components.skills.services.skill_loader import ( - SkillLoader, # noqa: TC001 + SkillLoader, ) from private_gpt.components.tools.tool_names import BASH_TOOL_NAME from private_gpt.components.tools.tool_placeholders import BASH_TOOL_FN from private_gpt.events.models import TextBlock -from private_gpt.settings.settings import Settings # noqa: TC001 +from private_gpt.settings.settings import Settings if TYPE_CHECKING: from private_gpt.components.skills.models.skill_entities import SkillFilter diff --git a/private_gpt/launcher.py b/private_gpt/launcher.py index 14273a69..3de29c15 100644 --- a/private_gpt/launcher.py +++ b/private_gpt/launcher.py @@ -17,6 +17,9 @@ from injector import Injector from llama_index.core.embeddings import MockEmbedding from llama_index.core.settings import Settings as LlamaIndexSettings +from private_gpt.components.code_execution.code_execution_component import ( + CodeExecutionComponent, +) from private_gpt.components.embedding.embedding_component import EmbeddingComponent from private_gpt.components.llm.llm_component import LLMComponent from private_gpt.components.node_store.node_store_component import NodeStoreComponent @@ -82,6 +85,7 @@ def eager_loading(injector: Injector) -> None: logger.debug("Initializing auxiliar services") injector.get(PromptBuilderService) injector.get(ToolService) + injector.get(CodeExecutionComponent) def apply_migrations(injector: Injector) -> None: diff --git a/private_gpt/server/chat/chat_request_mapper.py b/private_gpt/server/chat/chat_request_mapper.py index b11dbf2c..d024beca 100644 --- a/private_gpt/server/chat/chat_request_mapper.py +++ b/private_gpt/server/chat/chat_request_mapper.py @@ -1,4 +1,5 @@ import re +import uuid from typing import Any, Literal from injector import inject, singleton @@ -210,7 +211,9 @@ class ChatRequestMapper: tool_context=body.tool_context or [], context=ResolvedContextConfig( correlation_id=body.correlation_id, - user_id=body.metadata.user_id if body.metadata else None, + user_id=body.metadata.user_id + if body.metadata and body.metadata.user_id + else str(uuid.uuid4()), maximum_loaded_skills=( body.maximum_loaded_skills if body.maximum_loaded_skills is not None diff --git a/private_gpt/settings/settings.py b/private_gpt/settings/settings.py index 3c8e6dd7..c1a82ac0 100644 --- a/private_gpt/settings/settings.py +++ b/private_gpt/settings/settings.py @@ -1402,10 +1402,6 @@ class SandboxSettings(BaseModel): description="Sandbox provider registered by the application layer. " "Defaults to null (disabled); set explicitly to enable sandbox usage.", ) - base_url: str | None = Field( - default=None, - description="Base URL for remote sandbox providers.", - ) timeout: int = Field( default=60, description="Default sandbox operation timeout in seconds.", @@ -1419,20 +1415,26 @@ class SandboxSettings(BaseModel): return value -class OpenSandboxSettings(BaseModel): - base_url: str = Field(description="Base URL of the OpenSandbox server.") - api_key: str | None = Field(default=None, description="API key for OpenSandbox.") - image: str = Field( - default="python:3.11", - description="Container image used for sandbox sessions.", +class BashSettings(BaseModel): + cpu_limit_seconds: int = Field( + default=30, + description="RLIMIT_CPU applied to each isolated bash subprocess.", ) - session_ttl_seconds: int = Field( - default=3600, - description="How long (seconds) a sandbox container lives before expiry.", + memory_limit_mb: int = Field( + default=512, + description="RLIMIT_AS in MB applied to each isolated bash subprocess.", ) - resource_limits: dict[str, str] = Field( - default_factory=lambda: {"cpu": "500m", "memory": "512Mi"}, - description="Resource limits applied to each sandbox container.", + fsize_limit_mb: int = Field( + default=50, + description="RLIMIT_FSIZE in MB applied to each isolated bash subprocess.", + ) + nproc_limit: int = Field( + default=50, + description="RLIMIT_NPROC applied to each isolated bash subprocess.", + ) + output_cap_bytes: int = Field( + default=10 * 1024 * 1024, + description="Hard cap on raw subprocess output bytes before LLM truncation.", ) @@ -1460,35 +1462,6 @@ class CodeExecutionSettings(BaseModel): description="Idle TTL in seconds before a local session kernel is destroyed. " "Workspace files are preserved for restart.", ) - skills_cache_path: str | None = Field( - default=None, - description="Directory for cached skill/plugin file trees. " - "Defaults to {workspace_path}/content_cache.", - ) - bash_cpu_limit_seconds: int = Field( - default=30, - description="RLIMIT_CPU applied to each isolated bash subprocess.", - ) - bash_memory_limit_mb: int = Field( - default=512, - description="RLIMIT_AS in MB applied to each isolated bash subprocess.", - ) - bash_fsize_limit_mb: int = Field( - default=50, - description="RLIMIT_FSIZE in MB applied to each isolated bash subprocess.", - ) - bash_nproc_limit: int = Field( - default=50, - description="RLIMIT_NPROC applied to each isolated bash subprocess.", - ) - bash_output_cap_bytes: int = Field( - default=10 * 1024 * 1024, - description="Hard cap on raw subprocess output bytes before LLM truncation.", - ) - opensandbox: OpenSandboxSettings | None = Field( - default=None, - description="OpenSandbox provider settings. Required when provider='opensandbox'.", - ) @field_validator("provider", mode="before") @classmethod @@ -1600,19 +1573,15 @@ class Settings(BaseModel): phoenix: ArizePhoenixSettings opik: OpikSettings sandbox: SandboxSettings - code_execution: CodeExecutionSettings = Field(default_factory=CodeExecutionSettings) + bash: BashSettings + code_execution: CodeExecutionSettings web_fetch: WebFetchSettings web_search: WebSearchSettings database_query: DatabaseQuerySettings brave: BraveSearchSettings - skills: SkillSettings = Field(default_factory=SkillSettings) - transformation: TransformationSettings = Field( - default_factory=TransformationSettings - ) - semaphore: SemaphoreSettings = Field( - default_factory=SemaphoreSettings, - description="Settings for the semaphore manager", - ) + skills: SkillSettings + transformation: TransformationSettings + semaphore: SemaphoreSettings """ diff --git a/pyproject.toml b/pyproject.toml index 7420cc70..be3cf27a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -409,6 +409,7 @@ ban-relative-imports = "all" [tool.ruff.lint.flake8-type-checking] strict = true runtime-evaluated-base-classes = ["pydantic.BaseModel"] +runtime-evaluated-decorators = ["injector.inject"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["D"] diff --git a/settings.yaml b/settings.yaml index 7d3a3370..620a8ed3 100644 --- a/settings.yaml +++ b/settings.yaml @@ -220,16 +220,21 @@ opik: sandbox: provider: ${PGPT_SANDBOX_PROVIDER:local} - base_url: ${PGPT_SANDBOX_URL:} timeout: ${PGPT_SANDBOX_TIMEOUT:60} - code_execution: provider: ${PGPT_CODE_EXECUTION_PROVIDER:local} workspace_path: ${PGPT_CODE_EXECUTION_WORKSPACE_PATH:} timeout: ${PGPT_CODE_EXECUTION_TIMEOUT:60} max_output_bytes: ${PGPT_CODE_EXECUTION_MAX_OUTPUT_BYTES:1048576} +bash: + cpu_limit_seconds: ${PGPT_BASH_CPU_LIMIT_SECONDS:30} + memory_limit_mb: ${PGPT_BASH_MEMORY_LIMIT_MB:512} + fsize_limit_mb: ${PGPT_BASH_FSIZE_LIMIT_MB:10} + nproc_limit: ${PGPT_BASH_NPROC_LIMIT:50} + output_cap_bytes: ${PGPT_BASH_OUTPUT_CAP_BYTES:1048576} + web_fetch: enabled: ${PGPT_WEBFETCH_ENABLED:false} timeout_seconds: ${PGPT_WEBFETCH_TIMEOUT_SECONDS:15}