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:
youhuanghe
2026-03-22 07:24:47 +00:00
committed by WangCham
parent c095e830c7
commit b64a23f9ac
19 changed files with 42 additions and 1824 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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