fix(box): restore sandbox config and shared mcp runtime

This commit is contained in:
Junyan Qin
2026-05-12 23:24:02 +08:00
parent afc37958c1
commit e4c674a9f0
25 changed files with 758 additions and 547 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import os
import sys
import typing
@@ -13,6 +14,7 @@ from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
from langbot_plugin.box.actions import LangBotToBoxAction
from ..utils import platform
from ..utils.managed_runtime import ManagedRuntimeConnector
@@ -27,6 +29,10 @@ _DEFAULT_PORT = 5410
_HEARTBEAT_INTERVAL_SEC = 20
# Top-level keys under ``box`` that are LangBot-internal and should not be
# forwarded to the Box runtime.
_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'})
def _get_box_config(ap) -> dict:
"""Return the 'box' section from instance config, with safe fallbacks."""
@@ -35,6 +41,15 @@ def _get_box_config(ap) -> dict:
return config_data.get('box', {})
def _get_runtime_endpoint(box_cfg: dict) -> str:
runtime_cfg = box_cfg.get('runtime') or {}
return str(runtime_cfg.get('endpoint', '')).strip()
def _filter_config_for_runtime(box_cfg: dict) -> dict:
return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS}
def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
"""Derive the WS relay base URL used for managed-process attach.
@@ -43,10 +58,19 @@ def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
"""
box_cfg = _get_box_config(ap)
# Explicit relay URL takes precedence.
runtime_url = str(box_cfg.get('runtime_url', '')).strip()
if runtime_url:
return runtime_url
# Explicit runtime endpoint takes precedence. The config value is a base
# URL; endpoint-specific paths are appended by the SDK client.
endpoint = _get_runtime_endpoint(box_cfg)
if endpoint:
parsed = urlparse(endpoint)
scheme = parsed.scheme or 'ws'
if scheme == 'ws':
scheme = 'http'
elif scheme == 'wss':
scheme = 'https'
host = parsed.hostname or '127.0.0.1'
port = parsed.port or _DEFAULT_PORT
return f'{scheme}://{host}:{port}'
# In Docker, relay lives on the box runtime container.
if platform.get_platform() == 'docker':
@@ -59,7 +83,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Connect to the Box runtime via action RPC.
Transport decision (mirrors Plugin runtime logic):
1. Docker / --standalone-box / explicit runtime_url -> WebSocket to external Box process
1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process
2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe)
3. Unix / macOS -> subprocess + stdio pipe
"""
@@ -74,7 +98,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.configured_runtime_url = self._load_configured_runtime_url()
self.configured_runtime_endpoint = self._load_configured_runtime_endpoint()
self.ws_relay_base_url = resolve_box_ws_relay_url(ap)
self.client = ActionRPCBoxClient(logger=ap.logger)
@@ -87,6 +111,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
parsed = urlparse(self.ws_relay_base_url)
self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_PORT
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
def _uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime.
@@ -94,17 +119,17 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
True when:
- Running inside Docker (Box runtime is a separate container)
- The ``--standalone-box`` CLI flag was passed
- An explicit ``runtime_url`` was configured
- An explicit ``runtime.endpoint`` was configured
"""
return bool(
self.configured_runtime_url
self.configured_runtime_endpoint
or platform.get_platform() == 'docker'
or platform.use_websocket_to_connect_box_runtime()
)
async def initialize(self) -> None:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_url:
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
@@ -141,6 +166,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self.ap.logger.info('Use stdio to connect to box runtime')
python_path = sys.executable
env = os.environ.copy()
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
connected = asyncio.Event()
connect_error: list[Exception] = []
@@ -168,12 +195,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
await self._start_runtime_subprocess(
env = os.environ.copy()
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
python_path = sys.executable
self.runtime_subprocess = await asyncio.create_subprocess_exec(
python_path,
'-m',
'langbot_plugin.box.server',
'--ws-control-port',
str(self._relay_port),
env=env,
)
self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait())
ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
await self._connect_ws(ws_url, '(windows) WebSocket')
@@ -191,8 +226,15 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
All endpoints share a single port; action RPC is at ``/rpc/ws``.
"""
if self.configured_runtime_url:
return self.configured_runtime_url
if self.configured_runtime_endpoint:
base = self.configured_runtime_endpoint.rstrip('/')
parsed = urlparse(base)
scheme = parsed.scheme or 'ws'
if scheme in ('http', 'https'):
scheme = 'wss' if scheme == 'https' else 'ws'
host = parsed.hostname or '127.0.0.1'
port = parsed.port or _DEFAULT_PORT
return f'{scheme}://{host}:{port}/rpc/ws'
if platform.get_platform() == 'docker':
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws'
@@ -242,6 +284,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._handler_task = asyncio.create_task(handler.run())
try:
await handler.call_action(CommonAction.PING, {})
if self._filtered_box_config:
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
connected.set()
await self._handler_task
@@ -292,5 +337,5 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
# -- config helpers ------------------------------------------------------
def _load_configured_runtime_url(self) -> str:
return str(_get_box_config(self.ap).get('runtime_url', '')).strip()
def _load_configured_runtime_endpoint(self) -> str:
return _get_runtime_endpoint(_get_box_config(self.ap))

View File

@@ -28,11 +28,6 @@ _MAX_RECENT_ERRORS = 50
_MIB = 1024 * 1024
def _is_path_under(path: str, root: str) -> bool:
"""Check whether *path* equals *root* or is a child of *root*."""
return path == root or path.startswith(f'{root}{os.sep}')
def _is_path_under(path: str, root: str) -> bool:
"""Check whether *path* equals *root* or is a child of *root*."""
return path == root or path.startswith(f'{root}{os.sep}')
@@ -57,9 +52,9 @@ class BoxService:
client = self._runtime_connector.client
self.client = client
self.output_limit_chars = output_limit_chars
self.shared_host_root = self._load_shared_host_root()
self.allowed_host_mount_roots = self._load_allowed_host_mount_roots()
self.default_host_workspace = self._load_default_host_workspace()
self.host_root = self._load_host_root()
self.allowed_mount_roots = self._load_allowed_mount_roots()
self.default_workspace = self._load_default_workspace()
self.profile = self._load_profile()
self.custom_image = self._load_custom_image()
self.workspace_quota_mb = self._load_workspace_quota_mb()
@@ -70,7 +65,7 @@ class BoxService:
self._reconnecting = False
async def initialize(self):
self._ensure_default_host_workspace()
self._ensure_default_workspace()
try:
if self._runtime_connector is not None:
await self._runtime_connector.initialize()
@@ -80,7 +75,7 @@ class BoxService:
self._connector_error = ''
self.ap.logger.info(
f'LangBot Box runtime initialized: profile={self.profile.name} '
f'default_workspace={self.default_host_workspace or "(none)"}'
f'default_workspace={self.default_workspace or "(none)"}'
)
except Exception as exc:
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
@@ -134,7 +129,7 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Podman or Docker to use sandbox features.')
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
try:
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
except BoxError as exc:
@@ -251,8 +246,8 @@ class BoxService:
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
if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None:
spec_payload['host_path'] = self.default_workspace
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None:
spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
@@ -280,10 +275,10 @@ class BoxService:
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)
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
return await self.client.get_managed_process(session_id, process_id)
def get_managed_process_websocket_url(self, session_id: str) -> str:
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> 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')
@@ -292,7 +287,7 @@ class BoxService:
if self._runtime_connector is not None
else 'http://127.0.0.1:5410'
)
return getter(session_id, ws_relay_base_url)
return getter(session_id, ws_relay_base_url, process_id)
def _serialize_result(self, result: BoxExecutionResult) -> dict:
stdout, stdout_truncated = self._truncate(result.stdout)
@@ -382,8 +377,11 @@ class BoxService:
'stderr_preview': stderr_preview,
}
def _load_allowed_host_mount_roots(self) -> list[str]:
configured_roots = _get_box_config(self.ap).get('allowed_host_mount_roots', [])
def _local_config(self) -> dict:
return _get_box_config(self.ap).get('local') or {}
def _load_allowed_mount_roots(self) -> list[str]:
configured_roots = self._local_config().get('allowed_mount_roots', [])
normalized_roots: list[str] = []
for root in configured_roots:
@@ -392,31 +390,31 @@ class BoxService:
continue
normalized_roots.append(os.path.realpath(os.path.abspath(root_value)))
if not normalized_roots and self.shared_host_root is not None:
normalized_roots.append(self.shared_host_root)
if not normalized_roots and self.host_root is not None:
normalized_roots.append(self.host_root)
return normalized_roots
def _load_shared_host_root(self) -> str | None:
shared_host_root = str(_get_box_config(self.ap).get('shared_host_root', '')).strip()
if not shared_host_root:
def _load_host_root(self) -> str | None:
host_root = str(self._local_config().get('host_root', '')).strip()
if not host_root:
return None
return os.path.realpath(os.path.abspath(shared_host_root))
return os.path.realpath(os.path.abspath(host_root))
def _load_default_host_workspace(self) -> str | None:
default_host_workspace = str(_get_box_config(self.ap).get('default_host_workspace', '')).strip()
if not default_host_workspace:
if self.shared_host_root is None:
def _load_default_workspace(self) -> str | None:
default_workspace = str(self._local_config().get('default_workspace', '')).strip()
if not default_workspace:
if self.host_root is None:
return None
default_host_workspace = os.path.join(self.shared_host_root, 'default')
return os.path.realpath(os.path.abspath(default_host_workspace))
default_workspace = os.path.join(self.host_root, 'default')
return os.path.realpath(os.path.abspath(default_workspace))
def _load_custom_image(self) -> str | None:
raw = str(_get_box_config(self.ap).get('image', '') or '').strip()
raw = str(self._local_config().get('image', '') or '').strip()
return raw or None
def _load_workspace_quota_mb(self) -> int | None:
raw_value = _get_box_config(self.ap).get('workspace_quota_mb')
raw_value = self._local_config().get('workspace_quota_mb')
if raw_value in (None, ''):
return None
try:
@@ -427,28 +425,28 @@ class BoxService:
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
return value
def _ensure_default_host_workspace(self):
if self.default_host_workspace is None:
def _ensure_default_workspace(self):
if self.default_workspace is None:
return
if os.path.isdir(self.default_host_workspace):
if os.path.isdir(self.default_workspace):
return
if os.path.exists(self.default_host_workspace):
raise BoxValidationError('default_host_workspace must point to a directory on the host')
if os.path.exists(self.default_workspace):
raise BoxValidationError('box.local.default_workspace must point to a directory on the host')
if not self.allowed_host_mount_roots:
if not self.allowed_mount_roots:
raise BoxValidationError(
'default_host_workspace cannot be created because no allowed_host_mount_roots are configured'
'box.local.default_workspace cannot be created because no allowed_mount_roots are configured'
)
for allowed_root in self.allowed_host_mount_roots:
if _is_path_under(self.default_host_workspace, allowed_root):
os.makedirs(self.default_host_workspace, exist_ok=True)
for allowed_root in self.allowed_mount_roots:
if _is_path_under(self.default_workspace, allowed_root):
os.makedirs(self.default_workspace, exist_ok=True)
return
allowed_roots = ', '.join(self.allowed_host_mount_roots)
raise BoxValidationError(f'default_host_workspace is outside allowed_host_mount_roots: {allowed_roots}')
allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
def _validate_host_mount(self, spec: BoxSpec):
if spec.host_path is None:
@@ -458,20 +456,18 @@ class BoxService:
if not os.path.isdir(host_path):
raise BoxValidationError('host_path must point to an existing directory on the host')
if not self.allowed_host_mount_roots:
raise BoxValidationError(
'host_path mounting is disabled because no allowed_host_mount_roots are configured'
)
if not self.allowed_mount_roots:
raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured')
for allowed_root in self.allowed_host_mount_roots:
for allowed_root in self.allowed_mount_roots:
if _is_path_under(host_path, allowed_root):
return
allowed_roots = ', '.join(self.allowed_host_mount_roots)
raise BoxValidationError(f'host_path is outside allowed_host_mount_roots: {allowed_roots}')
allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}')
def _load_profile(self) -> BoxProfile:
profile_name = str(_get_box_config(self.ap).get('profile', 'default')).strip() or 'default'
profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default'
profile = BUILTIN_PROFILES.get(profile_name)
if profile is None:
@@ -592,7 +588,7 @@ class BoxService:
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
)
if self.default_host_workspace:
if self.default_workspace:
guidance += (
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '

View File

@@ -1,4 +1,3 @@
from __future__ import annotations
"""Reusable workspace/session helpers built on top of Box.
This module is the middle layer between the raw Box runtime primitives and
@@ -14,6 +13,8 @@ Higher layers add their own semantics on top, for example:
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
"""
from __future__ import annotations
import os
import textwrap
from typing import Any
@@ -42,9 +43,10 @@ def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str =
if not host_path or not path:
return path
normalized_host = os.path.realpath(host_path)
if path.startswith(normalized_host + '/'):
return mount_path + path[len(normalized_host) :]
if path == normalized_host:
normalized_path = os.path.realpath(path)
if normalized_path.startswith(normalized_host + '/'):
return mount_path + normalized_path[len(normalized_host) :]
if normalized_path == normalized_host:
return mount_path
return path
@@ -86,22 +88,21 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
if not host_path or not command:
return command
normalized_host = os.path.realpath(host_path)
if not command.startswith(normalized_host + '/'):
normalized_command = os.path.realpath(command)
if not normalized_command.startswith(normalized_host + '/'):
return command
rel = command[len(normalized_host) + 1 :]
rel = normalized_command[len(normalized_host) + 1 :]
parts = rel.replace('\\', '/').split('/')
if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'):
return 'python'
return rewrite_mounted_path(command, host_path, mount_path=mount_path)
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [
filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))
]
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
def classify_python_workspace(host_path: str | None) -> str | None:
@@ -269,6 +270,7 @@ class BoxWorkspaceSession:
cpus: float | None = None,
memory_mb: int | None = None,
pids_limit: int | None = None,
persistent: bool = False,
):
self.box_service = box_service
self.session_id = session_id
@@ -283,6 +285,7 @@ class BoxWorkspaceSession:
self.cpus = cpus
self.memory_mb = memory_mb
self.pids_limit = pids_limit
self.persistent = persistent
def rewrite_path(self, path: str) -> str:
return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path)
@@ -297,6 +300,7 @@ class BoxWorkspaceSession:
'session_id': self.session_id,
'workdir': self.workdir,
'env': self.env,
'persistent': self.persistent,
}
if self.network is not None:
payload['network'] = self.network
@@ -388,17 +392,19 @@ class BoxWorkspaceSession:
command: str,
args: list[str] | None = None,
*,
process_id: str = 'default',
env: dict[str, str] | None = None,
cwd: str = '/workspace',
):
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
payload['process_id'] = process_id
return await self.box_service.start_managed_process(self.session_id, payload)
async def get_managed_process(self):
return await self.box_service.get_managed_process(self.session_id)
async def get_managed_process(self, process_id: str = 'default'):
return await self.box_service.get_managed_process(self.session_id, process_id)
def get_managed_process_websocket_url(self) -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id)
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
async def cleanup(self) -> None:
await self.box_service.client.delete_session(self.session_id)

View File

@@ -20,7 +20,7 @@ from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig as MCPServerBoxConfig, MCPSessionErrorPhase
class MCPSessionStatus(enum.Enum):
@@ -349,7 +349,7 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str:
return f'mcp-{self.server_uuid}'
return 'mcp-shared'
def _rewrite_path(self, path: str, host_path: str | None) -> str:
return self._box_stdio_runtime.rewrite_path(path, host_path)

View File

@@ -81,8 +81,14 @@ class BoxStdioSessionRuntime:
cpus=self.config.cpus,
memory_mb=self.config.memory_mb,
pids_limit=self.config.pids_limit,
persistent=True,
)
@property
def process_id(self) -> str:
"""Each MCP server gets a unique process_id within the shared session."""
return self.owner.server_uuid
def uses_box_stdio(self) -> bool:
if self.server_config.get('mode') != 'stdio':
return False
@@ -104,7 +110,9 @@ class BoxStdioSessionRuntime:
if host_path:
install_cmd = self.owner._detect_install_command(host_path)
if install_cmd:
self.ap.logger.info(f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}')
self.ap.logger.info(
f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}'
)
try:
result = await workspace.execute_raw(
install_cmd,
@@ -122,6 +130,7 @@ class BoxStdioSessionRuntime:
await workspace.start_managed_process(
self.server_config['command'],
self.server_config.get('args', []),
process_id=self.process_id,
env=self.server_config.get('env', {}),
)
except Exception:
@@ -129,10 +138,12 @@ class BoxStdioSessionRuntime:
raise
try:
websocket_url = workspace.get_managed_process_websocket_url()
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
self.owner.session = await self.owner.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
raise
@@ -150,7 +161,7 @@ class BoxStdioSessionRuntime:
consecutive_errors = 0
while not self.owner._shutdown_event.is_set():
try:
info = await workspace.get_managed_process()
info = await workspace.get_managed_process(self.process_id)
if isinstance(info, dict):
status = info.get('status', '')
else:
@@ -173,10 +184,13 @@ class BoxStdioSessionRuntime:
if not self.uses_box_stdio():
return
try:
await self._build_workspace().cleanup()
except Exception as exc:
self.ap.logger.warning(f'Failed to cleanup Box session for MCP server {self.server_name}: {exc}')
# In the shared-session model, we do not delete the session itself.
# The managed process exits independently; deleting the session would
# kill other MCP servers sharing the same container.
self.ap.logger.info(
f'MCP server {self.server_name}: process_id={self.process_id} cleanup complete '
f'(shared session {self.owner._build_box_session_id()} kept alive)'
)
def rewrite_path(self, path: str, host_path: str | None) -> str:
return rewrite_mounted_path(path, host_path)

View File

@@ -121,9 +121,7 @@ class NativeToolLoader(loader.ToolLoader):
)
box_service = self.ap.box_service
host_root = (
selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace
)
host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
if not host_root:
raise ValueError('No host workspace configured for file operations.')

View File

@@ -183,9 +183,9 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
box_service = getattr(self.ap, 'box_service', None)
workspace_root = getattr(box_service, 'default_host_workspace', None)
workspace_root = getattr(box_service, 'default_workspace', None)
if not workspace_root:
raise ValueError('No default host workspace configured for importing skills')
raise ValueError('No default workspace configured for importing skills')
normalized_path = str(sandbox_path).strip() or '/workspace'
if not normalized_path.startswith('/workspace'):

View File

@@ -105,14 +105,22 @@ monitoring:
# Number of expired rows to delete per table batch
delete_batch_size: 1000
box:
profile: 'default'
image: '' # Custom sandbox container image. Leave empty to use the profile default (python:3.11-slim).
runtime_url: '' # Action-RPC WebSocket URL of an external Box Runtime. Leave empty for auto-detection (stdio locally, Docker service in containers).
shared_host_root: './data/box' # For Docker deployment, use '/workspaces'
default_host_workspace: '' # Defaults to '<shared_host_root>/default'
allowed_host_mount_roots: # Defaults to ['<shared_host_root>'] when left empty
- './data/box'
- '/tmp'
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence.
runtime:
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
host_root: './data/box' # Base host directory for local workspace mounts. For Docker deployment, use '/workspaces'.
default_workspace: '' # Defaults to '<host_root>/default'.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box'
- '/tmp'
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
e2b:
api_key: '' # Can also be set via E2B_API_KEY env var.
api_url: '' # Custom API URL for self-hosted deployments.
template: '' # Default template ID (e.g. 'base', 'python-3.11').
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'

View File

@@ -54,6 +54,7 @@
}
],
"knowledge-bases": [],
"box-session-id-template": "{launcher_type}_{launcher_id}",
"rerank-model": "",
"rerank-top-k": 5
},

View File

@@ -124,6 +124,83 @@ stages:
field: __system.is_wizard
operator: neq
value: true
- name: box-session-id-template
label:
en_US: Sandbox Scope
zh_Hans: 沙箱作用域
zh_Hant: 沙箱作用域
ja_JP: サンドボックススコープ
vi_VN: Phạm vi Sandbox
th_TH: ขอบเขต Sandbox
es_ES: Alcance del Sandbox
ru_RU: Область песочницы
description:
en_US: Determines how sandbox environments are shared across messages.
zh_Hans: 决定沙箱环境在不同消息间的共享方式。
zh_Hant: 決定沙箱環境在不同訊息間的共享方式。
ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。
vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.
th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
type: select
required: false
default: "{launcher_type}_{launcher_id}"
options:
- name: "{global}"
label:
en_US: Global (shared by all)
zh_Hans: 全局(所有人共享)
zh_Hant: 全域(所有人共用)
ja_JP: グローバル(全員共有)
vi_VN: Toàn cục (chia sẻ cho tất cả)
th_TH: ทั่วไป (แชร์ทั้งหมด)
es_ES: Global (compartido por todos)
ru_RU: Глобальный (общий для всех)
- name: "{launcher_type}_{launcher_id}"
label:
en_US: Per chat (Recommended)
zh_Hans: 每个会话(推荐)
zh_Hant: 每個會話(推薦)
ja_JP: チャットごと(推奨)
vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị)
th_TH: ต่อแชท (แนะนำ)
es_ES: Por chat (Recomendado)
ru_RU: По чату (Рекомендуется)
- name: "{launcher_type}_{launcher_id}_{sender_id}"
label:
en_US: Per user in chat
zh_Hans: 会话中每个用户
zh_Hant: 會話中每個用戶
ja_JP: チャット内のユーザーごと
vi_VN: Mỗi người dùng trong cuộc trò chuyện
th_TH: ต่อผู้ใช้ในแชท
es_ES: Por usuario en chat
ru_RU: По пользователю в чате
- name: "{launcher_type}_{launcher_id}_{conversation_id}"
label:
en_US: Per conversation context
zh_Hans: 每个对话上下文
zh_Hant: 每個對話上下文
ja_JP: 会話コンテキストごと
vi_VN: Mỗi ngữ cảnh hội thoại
th_TH: ต่อบริบทการสนทนา
es_ES: Por contexto de conversación
ru_RU: По контексту разговора
- name: "{query_id}"
label:
en_US: Per message (isolated)
zh_Hans: 每条消息(完全隔离)
zh_Hant: 每條訊息(完全隔離)
ja_JP: メッセージごと(隔離)
vi_VN: Mỗi tin nhắn (cách ly)
th_TH: ต่อข้อความ (แยกส่วน)
es_ES: Por mensaje (aislado)
ru_RU: По сообщению (изолированно)
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: rerank-model
label:
en_US: Rerank Model