mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security
## Summary
When Podman/Docker is available, all stdio-mode MCP servers now automatically
run inside Box containers with dependency installation, path rewriting, and
lifecycle management. When no container runtime exists, LangBot starts normally
and stdio MCP falls back to host-direct execution.
## What changed
### MCP stdio → Box integration (mcp.py)
- Add `MCPServerBoxConfig` pydantic model for structured box configuration
with validation and defaults (network, host_path_mode, timeouts, resources)
- Auto-infer `host_path` from command/args with venv detection: recognizes
`.venv/bin/python` patterns and walks up to the project root
- Rewrite host paths to container `/workspace` paths transparently
- Replace venv python commands with container-native `python`
- Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
`pip install` inside the container before starting the MCP server
- Copy project to `/tmp` before install to handle read-only mounts
- Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
- Add Box managed process health monitoring (poll every 5s)
- Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
block of `_lifecycle_loop`, covering all exit paths
- Fix retry logic: `_ready_event` is only set after all retries exhaust
or on success, not on first failure
- Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`
### Box security (security.py — new)
- `validate_sandbox_security()` blocks dangerous host paths:
`/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
docker.sock, podman socket
- Called at the start of `CLISandboxBackend.start_session()`
### Box models (models.py)
- Add `BoxHostMountMode.NONE` — skips volume mount entirely
- Adjust `validate_host_mount_consistency` to allow arbitrary workdir
when `host_path_mode=NONE`
### Box backend (backend.py)
- Add `validate_sandbox_security()` call in `start_session()`
- Add `langbot.box.config_hash` label on containers for drift detection
- Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
- Add `cleanup_orphaned_containers()` to base class (no-op default) and
CLI implementation (single batched `rm -f` command)
### Box runtime (runtime.py)
- Call `cleanup_orphaned_containers()` during `initialize()` to remove
lingering containers from previous runs
### Box service (service.py)
- Graceful degradation: `initialize()` catches runtime errors and sets
`available=False` instead of crashing LangBot startup
- Add `available` property and guard on `execute_sandbox_tool()`
- Add `skip_host_mount_validation` parameter to `build_spec()` and
`create_session()` — MCP paths are admin-configured and trusted,
bypassing `allowed_host_mount_roots` restrictions meant for
LLM-generated sandbox_exec commands
### Default behavior
- stdio MCP servers automatically use Box when `box_service.available`
is True (Podman/Docker detected); no explicit `box` config needed
- When no container runtime exists, falls back to host-direct stdio
- MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
(for site-packages), `host_path_mode=ro`, `startup_timeout=120s`
### Tests
- `test_box_security.py`: blocked paths, safe paths, subpath rejection
- `test_mcp_box_integration.py`: config model, path rewriting, venv
unwrap, host_path inference, payload building, runtime info, box
availability check
- `test_box_service.py`: `BoxHostMountMode.NONE` validation tests
This commit is contained in:
@@ -4,6 +4,8 @@ import abc
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
@@ -12,7 +14,8 @@ import typing
|
||||
import uuid
|
||||
|
||||
from .errors import BoxError
|
||||
from .models import DEFAULT_BOX_MOUNT_PATH, BoxExecutionResult, BoxExecutionStatus, BoxSessionInfo, BoxSpec
|
||||
from .models import DEFAULT_BOX_MOUNT_PATH, BoxExecutionResult, BoxExecutionStatus, BoxHostMountMode, BoxSessionInfo, BoxSpec
|
||||
from .security import validate_sandbox_security
|
||||
|
||||
# Hard cap on raw subprocess output to prevent unbounded memory usage.
|
||||
# Container timeout already bounds duration, but fast commands can still
|
||||
@@ -54,6 +57,13 @@ class BaseSandboxBackend(abc.ABC):
|
||||
async def stop_session(self, session: BoxSessionInfo):
|
||||
pass
|
||||
|
||||
async def start_managed_process(self, session: BoxSessionInfo, spec):
|
||||
raise BoxError(f'{self.name} backend does not support managed processes')
|
||||
|
||||
async def cleanup_orphaned_containers(self):
|
||||
"""Remove lingering containers from previous runs. No-op by default."""
|
||||
pass
|
||||
|
||||
|
||||
class CLISandboxBackend(BaseSandboxBackend):
|
||||
command: str
|
||||
@@ -71,6 +81,8 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
return result.return_code == 0 and not result.timed_out
|
||||
|
||||
async def start_session(self, spec: BoxSpec) -> BoxSessionInfo:
|
||||
validate_sandbox_security(spec)
|
||||
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
container_name = self._build_container_name(spec.session_id)
|
||||
|
||||
@@ -87,6 +99,19 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
f'langbot.session_id={spec.session_id}',
|
||||
]
|
||||
|
||||
# Config hash label for identifying configuration drift
|
||||
config_hash = hashlib.sha256(json.dumps({
|
||||
'image': spec.image,
|
||||
'network': spec.network.value,
|
||||
'host_path': spec.host_path,
|
||||
'host_path_mode': spec.host_path_mode.value,
|
||||
'cpus': spec.cpus,
|
||||
'memory_mb': spec.memory_mb,
|
||||
'pids_limit': spec.pids_limit,
|
||||
'read_only_rootfs': spec.read_only_rootfs,
|
||||
}, sort_keys=True).encode()).hexdigest()[:16]
|
||||
args.extend(['--label', f'langbot.box.config_hash={config_hash}'])
|
||||
|
||||
if spec.network.value == 'off':
|
||||
args.extend(['--network', 'none'])
|
||||
|
||||
@@ -99,7 +124,7 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
args.append('--read-only')
|
||||
args.extend(['--tmpfs', '/tmp:size=64m'])
|
||||
|
||||
if spec.host_path is not None:
|
||||
if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE:
|
||||
mount_spec = f'{spec.host_path}:{DEFAULT_BOX_MOUNT_PATH}:{spec.host_path_mode.value}'
|
||||
args.extend(['-v', mount_spec])
|
||||
|
||||
@@ -193,6 +218,54 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def cleanup_orphaned_containers(self):
|
||||
"""Remove any lingering langbot.box containers from previous runs."""
|
||||
result = await self._run_command(
|
||||
[self.command, 'ps', '-a', '--filter', 'label=langbot.box=true', '-q'],
|
||||
timeout_sec=10,
|
||||
check=False,
|
||||
)
|
||||
if result.return_code != 0 or not result.stdout.strip():
|
||||
return
|
||||
container_ids = [cid.strip() for cid in result.stdout.strip().split('\n') if cid.strip()]
|
||||
if not container_ids:
|
||||
return
|
||||
for cid in container_ids:
|
||||
self.logger.info(f'Cleaning up orphaned Box container: {cid}')
|
||||
await self._run_command(
|
||||
[self.command, 'rm', '-f', *container_ids],
|
||||
timeout_sec=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def start_managed_process(self, session: BoxSessionInfo, spec) -> asyncio.subprocess.Process:
|
||||
args = [self.command, 'exec', '-i']
|
||||
|
||||
for key, value in spec.env.items():
|
||||
args.extend(['-e', f'{key}={value}'])
|
||||
|
||||
args.extend(
|
||||
[
|
||||
session.backend_session_id,
|
||||
'sh',
|
||||
'-lc',
|
||||
self._build_spawn_command(spec.cwd, spec.command, spec.args),
|
||||
]
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
f'LangBot Box backend start_managed_process: backend={self.name} '
|
||||
f'session_id={session.session_id} container_name={session.backend_session_id} '
|
||||
f'cwd={spec.cwd} env_keys={sorted(spec.env.keys())} command={spec.command} args={spec.args}'
|
||||
)
|
||||
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
def _build_container_name(self, session_id: str) -> str:
|
||||
normalized = re.sub(r'[^a-zA-Z0-9_.-]+', '-', session_id).strip('-').lower() or 'session'
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
@@ -202,6 +275,11 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
quoted_workdir = shlex.quote(workdir)
|
||||
return f'mkdir -p {quoted_workdir} && cd {quoted_workdir} && {cmd}'
|
||||
|
||||
def _build_spawn_command(self, cwd: str, command: str, args: list[str]) -> str:
|
||||
quoted_cwd = shlex.quote(cwd)
|
||||
command_parts = [shlex.quote(command), *[shlex.quote(arg) for arg in args]]
|
||||
return f'mkdir -p {quoted_cwd} && cd {quoted_cwd} && exec {" ".join(command_parts)}'
|
||||
|
||||
async def _run_command(
|
||||
self,
|
||||
args: list[str],
|
||||
|
||||
@@ -11,12 +11,21 @@ import aiohttp
|
||||
from .errors import (
|
||||
BoxBackendUnavailableError,
|
||||
BoxError,
|
||||
BoxManagedProcessConflictError,
|
||||
BoxManagedProcessNotFoundError,
|
||||
BoxRuntimeUnavailableError,
|
||||
BoxSessionConflictError,
|
||||
BoxSessionNotFoundError,
|
||||
BoxValidationError,
|
||||
)
|
||||
from .models import BoxExecutionResult, BoxExecutionStatus, BoxSpec, get_box_config
|
||||
from .models import (
|
||||
BoxExecutionResult,
|
||||
BoxExecutionStatus,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxSpec,
|
||||
get_box_config,
|
||||
)
|
||||
from ..utils import platform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,6 +35,8 @@ _ERROR_CODE_MAP: dict[str, type[BoxError]] = {
|
||||
'validation_error': BoxValidationError,
|
||||
'session_not_found': BoxSessionNotFoundError,
|
||||
'session_conflict': BoxSessionConflictError,
|
||||
'managed_process_not_found': BoxManagedProcessNotFoundError,
|
||||
'managed_process_conflict': BoxManagedProcessConflictError,
|
||||
'backend_unavailable': BoxBackendUnavailableError,
|
||||
'runtime_unavailable': BoxRuntimeUnavailableError,
|
||||
'internal_error': BoxError,
|
||||
@@ -69,6 +80,12 @@ class BoxRuntimeClient(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
async def create_session(self, spec: BoxSpec) -> dict: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec) -> BoxManagedProcessInfo: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo: ...
|
||||
|
||||
|
||||
class RemoteBoxRuntimeClient(BoxRuntimeClient):
|
||||
"""HTTP client that talks to a standalone Box Runtime service."""
|
||||
@@ -182,3 +199,41 @@ class RemoteBoxRuntimeClient(BoxRuntimeClient):
|
||||
return await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise BoxRuntimeUnavailableError(f'box runtime unavailable: {exc}') from exc
|
||||
|
||||
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec) -> BoxManagedProcessInfo:
|
||||
session = self._get_session()
|
||||
payload = spec.model_dump(mode='json')
|
||||
try:
|
||||
async with session.post(
|
||||
f'{self._base_url}/v1/sessions/{session_id}/managed-process',
|
||||
json=payload,
|
||||
) as resp:
|
||||
await self._check_response(resp)
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise BoxRuntimeUnavailableError(f'box runtime unavailable: {exc}') from exc
|
||||
return BoxManagedProcessInfo.model_validate(data)
|
||||
|
||||
async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo:
|
||||
session = self._get_session()
|
||||
try:
|
||||
async with session.get(
|
||||
f'{self._base_url}/v1/sessions/{session_id}/managed-process',
|
||||
) as resp:
|
||||
await self._check_response(resp)
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise BoxRuntimeUnavailableError(f'box runtime unavailable: {exc}') from exc
|
||||
return BoxManagedProcessInfo.model_validate(data)
|
||||
|
||||
def get_managed_process_websocket_url(self, session_id: str) -> str:
|
||||
if self._base_url.startswith('https://'):
|
||||
scheme = 'wss://'
|
||||
suffix = self._base_url[len('https://'):]
|
||||
elif self._base_url.startswith('http://'):
|
||||
scheme = 'ws://'
|
||||
suffix = self._base_url[len('http://'):]
|
||||
else:
|
||||
scheme = 'ws://'
|
||||
suffix = self._base_url
|
||||
return f'{scheme}{suffix}/v1/sessions/{session_id}/managed-process/ws'
|
||||
|
||||
@@ -23,3 +23,11 @@ class BoxSessionConflictError(BoxError):
|
||||
|
||||
class BoxSessionNotFoundError(BoxError):
|
||||
"""Raised when a referenced session does not exist."""
|
||||
|
||||
|
||||
class BoxManagedProcessConflictError(BoxError):
|
||||
"""Raised when a session already has an active managed process."""
|
||||
|
||||
|
||||
class BoxManagedProcessNotFoundError(BoxError):
|
||||
"""Raised when a referenced managed process does not exist."""
|
||||
|
||||
@@ -28,10 +28,16 @@ class BoxExecutionStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class BoxHostMountMode(str, enum.Enum):
|
||||
NONE = 'none'
|
||||
READ_ONLY = 'ro'
|
||||
READ_WRITE = 'rw'
|
||||
|
||||
|
||||
class BoxManagedProcessStatus(str, enum.Enum):
|
||||
RUNNING = 'running'
|
||||
EXITED = 'exited'
|
||||
|
||||
|
||||
class BoxSpec(pydantic.BaseModel):
|
||||
cmd: str = ''
|
||||
workdir: str = '/workspace'
|
||||
@@ -116,6 +122,8 @@ class BoxSpec(pydantic.BaseModel):
|
||||
def validate_host_mount_consistency(self) -> 'BoxSpec':
|
||||
if self.host_path is None:
|
||||
return self
|
||||
if self.host_path_mode == BoxHostMountMode.NONE:
|
||||
return self
|
||||
if not self.workdir.startswith(DEFAULT_BOX_MOUNT_PATH):
|
||||
raise ValueError('workdir must stay under /workspace when host_path is provided')
|
||||
return self
|
||||
@@ -205,6 +213,53 @@ class BoxSessionInfo(pydantic.BaseModel):
|
||||
last_used_at: dt.datetime
|
||||
|
||||
|
||||
class BoxManagedProcessSpec(pydantic.BaseModel):
|
||||
command: str
|
||||
args: list[str] = pydantic.Field(default_factory=list)
|
||||
env: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
cwd: str = '/workspace'
|
||||
|
||||
@pydantic.field_validator('command')
|
||||
@classmethod
|
||||
def validate_command(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError('command must not be empty')
|
||||
return value
|
||||
|
||||
@pydantic.field_validator('args')
|
||||
@classmethod
|
||||
def validate_args(cls, value: list[str]) -> list[str]:
|
||||
return [str(item) for item in value]
|
||||
|
||||
@pydantic.field_validator('env')
|
||||
@classmethod
|
||||
def validate_env(cls, value: dict[str, str]) -> dict[str, str]:
|
||||
return {str(k): str(v) for k, v in value.items()}
|
||||
|
||||
@pydantic.field_validator('cwd')
|
||||
@classmethod
|
||||
def validate_cwd(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value.startswith('/'):
|
||||
raise ValueError('cwd must be an absolute path inside the sandbox')
|
||||
return value
|
||||
|
||||
|
||||
class BoxManagedProcessInfo(pydantic.BaseModel):
|
||||
session_id: str
|
||||
status: BoxManagedProcessStatus
|
||||
command: str
|
||||
args: list[str]
|
||||
cwd: str
|
||||
env_keys: list[str]
|
||||
attached: bool = False
|
||||
started_at: dt.datetime
|
||||
exited_at: dt.datetime | None = None
|
||||
exit_code: int | None = None
|
||||
stderr_preview: str = ''
|
||||
|
||||
|
||||
class BoxExecutionResult(pydantic.BaseModel):
|
||||
session_id: str
|
||||
backend_name: str
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from .backend import BaseSandboxBackend, DockerBackend, PodmanBackend
|
||||
from .errors import BoxBackendUnavailableError, BoxSessionConflictError, BoxSessionNotFoundError, BoxValidationError
|
||||
from .models import BoxExecutionResult, BoxExecutionStatus, BoxSessionInfo, BoxSpec
|
||||
from .errors import (
|
||||
BoxBackendUnavailableError,
|
||||
BoxManagedProcessConflictError,
|
||||
BoxManagedProcessNotFoundError,
|
||||
BoxSessionConflictError,
|
||||
BoxSessionNotFoundError,
|
||||
BoxValidationError,
|
||||
)
|
||||
from .models import (
|
||||
BoxExecutionResult,
|
||||
BoxExecutionStatus,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxManagedProcessStatus,
|
||||
BoxSessionInfo,
|
||||
BoxSpec,
|
||||
)
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
_MANAGED_PROCESS_STDERR_PREVIEW_LIMIT = 4000
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True)
|
||||
class _ManagedProcess:
|
||||
spec: BoxManagedProcessSpec
|
||||
process: asyncio.subprocess.Process
|
||||
started_at: dt.datetime
|
||||
attach_lock: asyncio.Lock
|
||||
stderr_chunks: collections.deque[str]
|
||||
exit_code: int | None = None
|
||||
exited_at: dt.datetime | None = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self.exit_code is None and self.process.returncode is None
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True)
|
||||
class _RuntimeSession:
|
||||
info: BoxSessionInfo
|
||||
lock: asyncio.Lock
|
||||
managed_process: _ManagedProcess | None = None
|
||||
|
||||
|
||||
class BoxRuntime:
|
||||
@@ -34,6 +67,11 @@ class BoxRuntime:
|
||||
|
||||
async def initialize(self):
|
||||
self._backend = await self._select_backend()
|
||||
if self._backend is not None:
|
||||
try:
|
||||
await self._backend.cleanup_orphaned_containers()
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'LangBot Box orphan container cleanup failed: {exc}')
|
||||
|
||||
async def execute(self, spec: BoxSpec) -> BoxExecutionResult:
|
||||
if not spec.cmd:
|
||||
@@ -77,6 +115,40 @@ class BoxRuntime:
|
||||
raise BoxSessionNotFoundError(f'session {session_id} not found')
|
||||
await self._drop_session_locked(session_id)
|
||||
|
||||
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec) -> dict:
|
||||
async with self._lock:
|
||||
runtime_session = self._sessions.get(session_id)
|
||||
if runtime_session is None:
|
||||
raise BoxSessionNotFoundError(f'session {session_id} not found')
|
||||
|
||||
async with runtime_session.lock:
|
||||
existing = runtime_session.managed_process
|
||||
if existing is not None and existing.is_running:
|
||||
raise BoxManagedProcessConflictError(f'session {session_id} already has a managed process')
|
||||
|
||||
backend = await self._get_backend()
|
||||
process = await backend.start_managed_process(runtime_session.info, spec)
|
||||
managed_process = _ManagedProcess(
|
||||
spec=spec,
|
||||
process=process,
|
||||
started_at=dt.datetime.now(_UTC),
|
||||
attach_lock=asyncio.Lock(),
|
||||
stderr_chunks=collections.deque(),
|
||||
)
|
||||
runtime_session.managed_process = managed_process
|
||||
runtime_session.info.last_used_at = dt.datetime.now(_UTC)
|
||||
asyncio.create_task(self._drain_managed_process_stderr(runtime_session.info.session_id, managed_process))
|
||||
asyncio.create_task(self._watch_managed_process(runtime_session.info.session_id, managed_process))
|
||||
return self._managed_process_to_dict(runtime_session.info.session_id, managed_process)
|
||||
|
||||
def get_managed_process(self, session_id: str) -> dict:
|
||||
runtime_session = self._sessions.get(session_id)
|
||||
if runtime_session is None:
|
||||
raise BoxSessionNotFoundError(f'session {session_id} not found')
|
||||
if runtime_session.managed_process is None:
|
||||
raise BoxManagedProcessNotFoundError(f'session {session_id} has no managed process')
|
||||
return self._managed_process_to_dict(session_id, runtime_session.managed_process)
|
||||
|
||||
# ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
async def get_backend_info(self) -> dict:
|
||||
@@ -97,6 +169,11 @@ class BoxRuntime:
|
||||
return {
|
||||
'backend': backend_info,
|
||||
'active_sessions': len(self._sessions),
|
||||
'managed_processes': sum(
|
||||
1
|
||||
for runtime_session in self._sessions.values()
|
||||
if runtime_session.managed_process is not None and runtime_session.managed_process.is_running
|
||||
),
|
||||
'session_ttl_sec': self.session_ttl_sec,
|
||||
}
|
||||
|
||||
@@ -163,6 +240,7 @@ class BoxRuntime:
|
||||
session_id
|
||||
for session_id, session in self._sessions.items()
|
||||
if session.info.last_used_at < deadline
|
||||
and not (session.managed_process is not None and session.managed_process.is_running)
|
||||
]
|
||||
|
||||
for session_id in expired_session_ids:
|
||||
@@ -173,6 +251,8 @@ class BoxRuntime:
|
||||
if runtime_session is None or self._backend is None:
|
||||
return
|
||||
|
||||
await self._terminate_managed_process(runtime_session)
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
'LangBot Box session cleanup: '
|
||||
@@ -198,6 +278,90 @@ class BoxRuntime:
|
||||
f'sandbox_exec session {spec.session_id} already exists with {field}={display}'
|
||||
)
|
||||
|
||||
async def _drain_managed_process_stderr(self, session_id: str, managed_process: _ManagedProcess) -> None:
|
||||
stream = managed_process.process.stderr
|
||||
if stream is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = await stream.readline()
|
||||
if not chunk:
|
||||
break
|
||||
text = chunk.decode('utf-8', errors='replace').rstrip()
|
||||
if not text:
|
||||
continue
|
||||
managed_process.stderr_chunks.append(text)
|
||||
preview = '\n'.join(managed_process.stderr_chunks)
|
||||
while len(preview) > _MANAGED_PROCESS_STDERR_PREVIEW_LIMIT and managed_process.stderr_chunks:
|
||||
managed_process.stderr_chunks.popleft()
|
||||
preview = '\n'.join(managed_process.stderr_chunks)
|
||||
self.logger.info(f'LangBot Box managed process stderr: session_id={session_id} {text}')
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'Failed to drain managed process stderr for {session_id}: {exc}')
|
||||
|
||||
async def _watch_managed_process(self, session_id: str, managed_process: _ManagedProcess) -> None:
|
||||
return_code = await managed_process.process.wait()
|
||||
managed_process.exit_code = return_code
|
||||
managed_process.exited_at = dt.datetime.now(_UTC)
|
||||
runtime_session = self._sessions.get(session_id)
|
||||
if runtime_session is not None:
|
||||
runtime_session.info.last_used_at = managed_process.exited_at
|
||||
self.logger.info(
|
||||
'LangBot Box managed process exited: '
|
||||
f'session_id={session_id} return_code={return_code}'
|
||||
)
|
||||
|
||||
async def _terminate_managed_process(self, runtime_session: _RuntimeSession) -> None:
|
||||
managed_process = runtime_session.managed_process
|
||||
if managed_process is None or not managed_process.is_running:
|
||||
return
|
||||
|
||||
process = managed_process.process
|
||||
try:
|
||||
if process.stdin is not None:
|
||||
process.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(process.wait()), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
if process.returncode is None:
|
||||
try:
|
||||
process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(process.wait()), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
if process.returncode is None:
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await process.wait()
|
||||
finally:
|
||||
managed_process.exit_code = process.returncode
|
||||
managed_process.exited_at = dt.datetime.now(_UTC)
|
||||
|
||||
def _managed_process_to_dict(self, session_id: str, managed_process: _ManagedProcess) -> dict:
|
||||
stderr_preview = '\n'.join(managed_process.stderr_chunks)
|
||||
status = BoxManagedProcessStatus.RUNNING if managed_process.is_running else BoxManagedProcessStatus.EXITED
|
||||
return BoxManagedProcessInfo(
|
||||
session_id=session_id,
|
||||
status=status,
|
||||
command=managed_process.spec.command,
|
||||
args=managed_process.spec.args,
|
||||
cwd=managed_process.spec.cwd,
|
||||
env_keys=sorted(managed_process.spec.env.keys()),
|
||||
attached=managed_process.attach_lock.locked(),
|
||||
started_at=managed_process.started_at,
|
||||
exited_at=managed_process.exited_at,
|
||||
exit_code=managed_process.exit_code,
|
||||
stderr_preview=stderr_preview,
|
||||
).model_dump(mode='json')
|
||||
|
||||
@staticmethod
|
||||
def _session_to_dict(info: BoxSessionInfo) -> dict:
|
||||
return {
|
||||
|
||||
42
src/langbot/pkg/box/security.py
Normal file
42
src/langbot/pkg/box/security.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .errors import BoxValidationError
|
||||
from .models import BoxSpec
|
||||
|
||||
BLOCKED_HOST_PATHS = frozenset({
|
||||
'/etc',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/root',
|
||||
'/boot',
|
||||
'/run',
|
||||
'/var/run',
|
||||
'/run/docker.sock',
|
||||
'/var/run/docker.sock',
|
||||
'/run/podman',
|
||||
'/var/run/podman',
|
||||
})
|
||||
|
||||
RESERVED_CONTAINER_PATHS = frozenset({
|
||||
'/workspace',
|
||||
'/tmp',
|
||||
'/var/tmp',
|
||||
'/run',
|
||||
})
|
||||
|
||||
|
||||
def validate_sandbox_security(spec: BoxSpec) -> None:
|
||||
"""Validate that a BoxSpec does not request dangerous container config.
|
||||
|
||||
Raises BoxValidationError when the spec contains a blocked host_path.
|
||||
"""
|
||||
if spec.host_path:
|
||||
real = os.path.realpath(spec.host_path)
|
||||
for blocked in BLOCKED_HOST_PATHS:
|
||||
if real == blocked or real.startswith(blocked + '/'):
|
||||
raise BoxValidationError(
|
||||
f'host_path {spec.host_path} is blocked for security'
|
||||
)
|
||||
@@ -7,6 +7,8 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
import pydantic
|
||||
@@ -15,11 +17,13 @@ from aiohttp import web
|
||||
from .errors import (
|
||||
BoxBackendUnavailableError,
|
||||
BoxError,
|
||||
BoxManagedProcessConflictError,
|
||||
BoxManagedProcessNotFoundError,
|
||||
BoxSessionConflictError,
|
||||
BoxSessionNotFoundError,
|
||||
BoxValidationError,
|
||||
)
|
||||
from .models import BoxExecutionResult, BoxSpec
|
||||
from .models import BoxExecutionResult, BoxManagedProcessSpec, BoxSpec
|
||||
from .runtime import BoxRuntime
|
||||
|
||||
logger = logging.getLogger('langbot.box.server')
|
||||
@@ -28,6 +32,8 @@ _ERROR_MAP: dict[type, tuple[int, str]] = {
|
||||
BoxValidationError: (400, 'validation_error'),
|
||||
BoxSessionNotFoundError: (404, 'session_not_found'),
|
||||
BoxSessionConflictError: (409, 'session_conflict'),
|
||||
BoxManagedProcessNotFoundError: (404, 'managed_process_not_found'),
|
||||
BoxManagedProcessConflictError: (409, 'managed_process_conflict'),
|
||||
BoxBackendUnavailableError: (503, 'backend_unavailable'),
|
||||
}
|
||||
|
||||
@@ -129,6 +135,91 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
async def handle_start_managed_process(request: web.Request) -> web.Response:
|
||||
runtime: BoxRuntime = request.app['runtime']
|
||||
session_id = request.match_info['session_id']
|
||||
try:
|
||||
body = await request.json()
|
||||
spec = BoxManagedProcessSpec.model_validate(body)
|
||||
process_info = await runtime.start_managed_process(session_id, spec)
|
||||
return web.json_response(process_info, status=201)
|
||||
except pydantic.ValidationError as exc:
|
||||
return web.json_response(
|
||||
{'error': {'code': 'validation_error', 'message': str(exc)}},
|
||||
status=400,
|
||||
)
|
||||
except BoxError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
async def handle_get_managed_process(request: web.Request) -> web.Response:
|
||||
runtime: BoxRuntime = request.app['runtime']
|
||||
session_id = request.match_info['session_id']
|
||||
try:
|
||||
return web.json_response(runtime.get_managed_process(session_id))
|
||||
except BoxError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
async def handle_managed_process_ws(request: web.Request) -> web.StreamResponse:
|
||||
runtime: BoxRuntime = request.app['runtime']
|
||||
session_id = request.match_info['session_id']
|
||||
|
||||
runtime_session = runtime._sessions.get(session_id)
|
||||
if runtime_session is None:
|
||||
return _error_response(BoxSessionNotFoundError(f'session {session_id} not found'))
|
||||
|
||||
managed_process = runtime_session.managed_process
|
||||
if managed_process is None:
|
||||
return _error_response(BoxManagedProcessNotFoundError(f'session {session_id} has no managed process'))
|
||||
if not managed_process.is_running:
|
||||
return _error_response(BoxManagedProcessConflictError(f'managed process in session {session_id} is not running'))
|
||||
|
||||
ws = web.WebSocketResponse(protocols=('mcp',))
|
||||
await ws.prepare(request)
|
||||
|
||||
async with managed_process.attach_lock:
|
||||
process = managed_process.process
|
||||
stdout = process.stdout
|
||||
stdin = process.stdin
|
||||
if stdout is None or stdin is None:
|
||||
await ws.close(message=b'managed process stdio unavailable')
|
||||
return ws
|
||||
|
||||
async def _stdout_to_ws() -> None:
|
||||
while True:
|
||||
line = await stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
await ws.send_str(line.decode('utf-8', errors='replace').rstrip('\n'))
|
||||
runtime_session.info.last_used_at = dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
async def _ws_to_stdin() -> None:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
stdin.write((msg.data + '\n').encode('utf-8'))
|
||||
await stdin.drain()
|
||||
runtime_session.info.last_used_at = dt.datetime.now(dt.timezone.utc)
|
||||
elif msg.type in (web.WSMsgType.CLOSE, web.WSMsgType.CLOSING, web.WSMsgType.CLOSED, web.WSMsgType.ERROR):
|
||||
break
|
||||
|
||||
stdout_task = asyncio.create_task(_stdout_to_ws())
|
||||
stdin_task = asyncio.create_task(_ws_to_stdin())
|
||||
try:
|
||||
done, pending = await asyncio.wait(
|
||||
[stdout_task, stdin_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
task.result()
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
def create_app(runtime: BoxRuntime | None = None) -> web.Application:
|
||||
"""Create the aiohttp Application with all routes.
|
||||
|
||||
@@ -145,6 +236,9 @@ def create_app(runtime: BoxRuntime | None = None) -> web.Application:
|
||||
app.router.add_post('/v1/sessions/{session_id}', handle_create_session)
|
||||
app.router.add_get('/v1/sessions', handle_get_sessions)
|
||||
app.router.add_delete('/v1/sessions/{session_id}', handle_delete_session)
|
||||
app.router.add_post('/v1/sessions/{session_id}/managed-process', handle_start_managed_process)
|
||||
app.router.add_get('/v1/sessions/{session_id}/managed-process', handle_get_managed_process)
|
||||
app.router.add_get('/v1/sessions/{session_id}/managed-process/ws', handle_managed_process_ws)
|
||||
app.router.add_get('/v1/status', handle_status)
|
||||
app.router.add_get('/v1/health', handle_health)
|
||||
|
||||
|
||||
@@ -12,7 +12,15 @@ import pydantic
|
||||
from .client import BoxRuntimeClient
|
||||
from .connector import BoxRuntimeConnector
|
||||
from .errors import BoxError, BoxValidationError
|
||||
from .models import BUILTIN_PROFILES, BoxExecutionResult, BoxProfile, BoxSpec, get_box_config
|
||||
from .models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxProfile,
|
||||
BoxSpec,
|
||||
get_box_config,
|
||||
)
|
||||
|
||||
_INT_ADAPTER = pydantic.TypeAdapter(int)
|
||||
_UTC = _dt.timezone.utc
|
||||
@@ -42,32 +50,36 @@ class BoxService:
|
||||
self.profile = self._load_profile()
|
||||
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
||||
self._shutdown_task = None
|
||||
self._available = False
|
||||
|
||||
async def initialize(self):
|
||||
self._ensure_default_host_workspace()
|
||||
if self._runtime_connector is not None:
|
||||
await self._runtime_connector.initialize()
|
||||
return
|
||||
await self.client.initialize()
|
||||
try:
|
||||
if self._runtime_connector is not None:
|
||||
await self._runtime_connector.initialize()
|
||||
else:
|
||||
await self.client.initialize()
|
||||
self._available = True
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'LangBot Box runtime unavailable, sandbox features disabled: {exc}'
|
||||
)
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
async def execute_sandbox_tool(self, parameters: dict, query: 'pipeline_query.Query') -> dict:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Podman or Docker to use sandbox features.')
|
||||
spec_payload = dict(parameters)
|
||||
spec_payload.setdefault('session_id', str(query.query_id))
|
||||
spec_payload.setdefault('env', {})
|
||||
if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None:
|
||||
spec_payload['host_path'] = self.default_host_workspace
|
||||
|
||||
self._apply_profile(spec_payload)
|
||||
|
||||
try:
|
||||
spec = BoxSpec.model_validate(spec_payload)
|
||||
except pydantic.ValidationError as exc:
|
||||
first_error = exc.errors()[0]
|
||||
err = BoxValidationError(first_error.get('msg', 'invalid sandbox_exec arguments'))
|
||||
self._record_error(err, query)
|
||||
raise err from exc
|
||||
|
||||
self._validate_host_mount(spec)
|
||||
spec = self.build_spec(spec_payload)
|
||||
except BoxError as exc:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
self.ap.logger.info(
|
||||
'LangBot Box request: '
|
||||
f'query_id={query.query_id} '
|
||||
@@ -102,6 +114,41 @@ class BoxService:
|
||||
async def get_sessions(self) -> list[dict]:
|
||||
return await self.client.get_sessions()
|
||||
|
||||
def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec:
|
||||
spec_payload = dict(spec_payload)
|
||||
spec_payload.setdefault('env', {})
|
||||
if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None:
|
||||
spec_payload['host_path'] = self.default_host_workspace
|
||||
|
||||
self._apply_profile(spec_payload)
|
||||
|
||||
try:
|
||||
spec = BoxSpec.model_validate(spec_payload)
|
||||
except pydantic.ValidationError as exc:
|
||||
first_error = exc.errors()[0]
|
||||
raise BoxValidationError(first_error.get('msg', 'invalid box arguments')) from exc
|
||||
|
||||
if not skip_host_mount_validation:
|
||||
self._validate_host_mount(spec)
|
||||
return spec
|
||||
|
||||
async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
|
||||
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||
return await self.client.create_session(spec)
|
||||
|
||||
async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
|
||||
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
|
||||
return await self.client.start_managed_process(session_id, process_spec)
|
||||
|
||||
async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo:
|
||||
return await self.client.get_managed_process(session_id)
|
||||
|
||||
def get_managed_process_websocket_url(self, session_id: str) -> str:
|
||||
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
|
||||
if getter is None:
|
||||
raise BoxValidationError('box runtime client does not support managed process websocket attach')
|
||||
return getter(session_id)
|
||||
|
||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||
@@ -296,9 +343,16 @@ class BoxService:
|
||||
return list(self._recent_errors)
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
if not self._available:
|
||||
return {
|
||||
'available': False,
|
||||
'profile': self.profile.name,
|
||||
'recent_error_count': len(self._recent_errors),
|
||||
}
|
||||
runtime_status = await self.client.get_status()
|
||||
return {
|
||||
**runtime_status,
|
||||
'available': True,
|
||||
'profile': self.profile.name,
|
||||
'recent_error_count': len(self._recent_errors),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user