feat: use sandbox inside of code executor

This commit is contained in:
Javier Martinez
2026-06-10 21:27:12 +08:00
parent 555e22cb5e
commit 3daea36ba9
16 changed files with 585 additions and 391 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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"]

View File

@@ -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}