mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 08:46:02 +00:00
refactor(box): move box runtime to langbot-plugin-sdk
Extract self-contained box runtime modules (actions, backend, client, errors, models, runtime, security, server) to langbot-plugin-sdk and update all imports to use `langbot_plugin.box.*`. Keep only service and connector in LangBot core as they depend on the Application context. - Update docker-compose to use `langbot_plugin.box.server` entry point - Update pyproject.toml to use local SDK via `tool.uv.sources` - Remove migrated source files and their unit/integration tests - Update remaining test imports to match new module paths
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.backend import CLISandboxBackend, _MAX_RAW_OUTPUT_BYTES
|
||||
|
||||
|
||||
class TestClipCapturedBytes:
|
||||
def test_within_limit_unchanged(self):
|
||||
data = b'hello world'
|
||||
result = CLISandboxBackend._clip_captured_bytes(data, total_size=len(data), limit=1024)
|
||||
assert result == 'hello world'
|
||||
|
||||
def test_exceeding_limit_clips_and_appends_notice(self):
|
||||
captured = b'A' * 100
|
||||
total_size = 200
|
||||
result = CLISandboxBackend._clip_captured_bytes(captured, total_size=total_size, limit=100)
|
||||
assert result.startswith('A' * 100)
|
||||
assert 'raw output clipped at 100 bytes' in result
|
||||
assert '100 bytes discarded' in result
|
||||
|
||||
def test_exact_limit_not_clipped(self):
|
||||
data = b'B' * 100
|
||||
result = CLISandboxBackend._clip_captured_bytes(data, total_size=100, limit=100)
|
||||
assert result == 'B' * 100
|
||||
assert 'clipped' not in result
|
||||
|
||||
def test_default_limit_is_module_constant(self):
|
||||
data = b'x' * 10
|
||||
result = CLISandboxBackend._clip_captured_bytes(data, total_size=10)
|
||||
assert result == 'x' * 10
|
||||
assert _MAX_RAW_OUTPUT_BYTES == 1_048_576
|
||||
|
||||
def test_invalid_utf8_replaced(self):
|
||||
data = b'ok\xff\xfetail'
|
||||
result = CLISandboxBackend._clip_captured_bytes(data, total_size=len(data), limit=1024)
|
||||
assert 'ok' in result
|
||||
assert 'tail' in result
|
||||
@@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.client import ActionRPCBoxClient
|
||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||
from langbot.pkg.box.connector import BoxRuntimeConnector
|
||||
from langbot.pkg.box.errors import BoxRuntimeUnavailableError
|
||||
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
|
||||
|
||||
|
||||
def make_app(logger: Mock, runtime_url: str = ''):
|
||||
@@ -27,7 +27,6 @@ def make_app(logger: Mock, runtime_url: str = ''):
|
||||
|
||||
|
||||
def patch_platform(monkeypatch: pytest.MonkeyPatch, value: str):
|
||||
monkeypatch.setattr('langbot.pkg.box.client.platform.get_platform', lambda: value)
|
||||
monkeypatch.setattr('langbot.pkg.box.connector.platform.get_platform', lambda: value)
|
||||
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.backend import BaseSandboxBackend
|
||||
from langbot.pkg.box.models import BoxManagedProcessSpec, BoxManagedProcessStatus, BoxSessionInfo, BoxSpec
|
||||
from langbot.pkg.box.runtime import BoxRuntime
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
|
||||
|
||||
class FakeManagedProcessBackend(BaseSandboxBackend):
|
||||
name = 'fake-managed'
|
||||
|
||||
def __init__(self, logger: Mock):
|
||||
super().__init__(logger)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
async def start_session(self, spec: BoxSpec) -> BoxSessionInfo:
|
||||
now = dt.datetime.now(_UTC)
|
||||
return BoxSessionInfo(
|
||||
session_id=spec.session_id,
|
||||
backend_name=self.name,
|
||||
backend_session_id=f'backend-{spec.session_id}',
|
||||
image=spec.image,
|
||||
network=spec.network,
|
||||
host_path=spec.host_path,
|
||||
host_path_mode=spec.host_path_mode,
|
||||
cpus=spec.cpus,
|
||||
memory_mb=spec.memory_mb,
|
||||
pids_limit=spec.pids_limit,
|
||||
read_only_rootfs=spec.read_only_rootfs,
|
||||
created_at=now,
|
||||
last_used_at=now,
|
||||
)
|
||||
|
||||
async def exec(self, session: BoxSessionInfo, spec: BoxSpec):
|
||||
raise NotImplementedError
|
||||
|
||||
async def stop_session(self, session: BoxSessionInfo):
|
||||
return None
|
||||
|
||||
async def start_managed_process(self, session: BoxSessionInfo, spec: BoxManagedProcessSpec) -> asyncio.subprocess.Process:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
'sh',
|
||||
'-lc',
|
||||
'cat',
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_start_managed_process_tracks_status():
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeManagedProcessBackend(logger)], session_ttl_sec=300)
|
||||
await runtime.initialize()
|
||||
|
||||
session_spec = BoxSpec.model_validate({'cmd': 'echo bootstrap', 'session_id': 'mcp-session'})
|
||||
await runtime.create_session(session_spec)
|
||||
|
||||
process_info = await runtime.start_managed_process(
|
||||
'mcp-session',
|
||||
BoxManagedProcessSpec(command='python', args=['-m', 'demo'], cwd='/workspace'),
|
||||
)
|
||||
|
||||
assert process_info['session_id'] == 'mcp-session'
|
||||
assert process_info['status'] == BoxManagedProcessStatus.RUNNING.value
|
||||
assert process_info['command'] == 'python'
|
||||
assert process_info['args'] == ['-m', 'demo']
|
||||
|
||||
queried = runtime.get_managed_process('mcp-session')
|
||||
assert queried['status'] == BoxManagedProcessStatus.RUNNING.value
|
||||
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_does_not_reap_session_with_running_managed_process():
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeManagedProcessBackend(logger)], session_ttl_sec=1)
|
||||
await runtime.initialize()
|
||||
|
||||
session_spec = BoxSpec.model_validate({'cmd': 'echo bootstrap', 'session_id': 'mcp-session'})
|
||||
await runtime.create_session(session_spec)
|
||||
await runtime.start_managed_process(
|
||||
'mcp-session',
|
||||
BoxManagedProcessSpec(command='python', args=['-m', 'demo'], cwd='/workspace'),
|
||||
)
|
||||
|
||||
runtime._sessions['mcp-session'].info.last_used_at = dt.datetime.now(_UTC) - dt.timedelta(seconds=120)
|
||||
await runtime._reap_expired_sessions_locked()
|
||||
|
||||
assert 'mcp-session' in runtime._sessions
|
||||
|
||||
await runtime.shutdown()
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.errors import BoxValidationError
|
||||
from langbot.pkg.box.models import BoxHostMountMode, BoxNetworkMode, BoxSpec
|
||||
from langbot.pkg.box.security import BLOCKED_HOST_PATHS, validate_sandbox_security
|
||||
|
||||
|
||||
def _make_spec(**overrides) -> BoxSpec:
|
||||
defaults = {
|
||||
'session_id': 'test-session',
|
||||
'cmd': 'echo hi',
|
||||
'image': 'python:3.11-slim',
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return BoxSpec(**defaults)
|
||||
|
||||
|
||||
class TestValidateSandboxSecurity:
|
||||
def test_no_host_path_passes(self):
|
||||
spec = _make_spec(host_path=None)
|
||||
validate_sandbox_security(spec) # should not raise
|
||||
|
||||
def test_safe_host_path_passes(self):
|
||||
spec = _make_spec(host_path='/home/user/my-project')
|
||||
validate_sandbox_security(spec) # should not raise
|
||||
|
||||
@pytest.mark.parametrize('blocked', [
|
||||
'/etc',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/root',
|
||||
'/boot',
|
||||
'/run',
|
||||
'/var/run',
|
||||
'/run/docker.sock',
|
||||
'/var/run/docker.sock',
|
||||
'/run/podman',
|
||||
'/var/run/podman',
|
||||
])
|
||||
def test_blocked_paths_rejected(self, blocked):
|
||||
spec = _make_spec(host_path=blocked)
|
||||
with pytest.raises(BoxValidationError, match='blocked for security'):
|
||||
validate_sandbox_security(spec)
|
||||
|
||||
def test_blocked_subpath_rejected(self):
|
||||
spec = _make_spec(host_path='/etc/nginx')
|
||||
with pytest.raises(BoxValidationError, match='blocked for security'):
|
||||
validate_sandbox_security(spec)
|
||||
|
||||
def test_path_starting_with_blocked_prefix_but_different_dir_passes(self):
|
||||
# /etcetera is NOT /etc
|
||||
spec = _make_spec(host_path='/etcetera/data')
|
||||
validate_sandbox_security(spec) # should not raise
|
||||
|
||||
def test_blocked_host_paths_is_frozenset(self):
|
||||
assert isinstance(BLOCKED_HOST_PATHS, frozenset)
|
||||
@@ -10,10 +10,10 @@ import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
from langbot.pkg.box.backend import BaseSandboxBackend
|
||||
from langbot.pkg.box.client import BoxRuntimeClient, ActionRPCBoxClient
|
||||
from langbot.pkg.box.errors import BoxBackendUnavailableError, BoxSessionConflictError, BoxSessionNotFoundError, BoxValidationError
|
||||
from langbot.pkg.box.models import (
|
||||
from langbot_plugin.box.backend import BaseSandboxBackend
|
||||
from langbot_plugin.box.client import BoxRuntimeClient, ActionRPCBoxClient
|
||||
from langbot_plugin.box.errors import BoxBackendUnavailableError, BoxSessionConflictError, BoxSessionNotFoundError, BoxValidationError
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
BoxExecutionStatus,
|
||||
@@ -24,7 +24,7 @@ from langbot.pkg.box.models import (
|
||||
BoxSessionInfo,
|
||||
BoxSpec,
|
||||
)
|
||||
from langbot.pkg.box.runtime import BoxRuntime
|
||||
from langbot_plugin.box.runtime import BoxRuntime
|
||||
from langbot.pkg.box.service import BoxService
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
@@ -803,7 +803,7 @@ def _make_queue_connection_pair():
|
||||
|
||||
async def _make_rpc_pair(runtime: BoxRuntime):
|
||||
"""Create an in-process (ActionRPCBoxClient, server_task, client_task) connected via queues."""
|
||||
from langbot.pkg.box.server import BoxServerHandler
|
||||
from langbot_plugin.box.server import BoxServerHandler
|
||||
from langbot_plugin.runtime.io.handler import Handler
|
||||
|
||||
client_conn, server_conn = _make_queue_connection_pair()
|
||||
|
||||
@@ -93,8 +93,8 @@ def mcp_module():
|
||||
class _BPS(str, _enum.Enum):
|
||||
RUNNING = 'running'
|
||||
EXITED = 'exited'
|
||||
_save_and_stub('langbot.pkg.box', is_package=True)
|
||||
_save_and_stub('langbot.pkg.box.models', {'BoxManagedProcessStatus': _BPS})
|
||||
_save_and_stub('langbot_plugin.box', is_package=True)
|
||||
_save_and_stub('langbot_plugin.box.models', {'BoxManagedProcessStatus': _BPS})
|
||||
|
||||
# Now load mcp.py via spec_from_file_location
|
||||
mod_fqn = 'langbot.pkg.provider.tools.loaders.mcp'
|
||||
|
||||
Reference in New Issue
Block a user