mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 04:24:36 +00:00
fix: ruff
This commit is contained in:
@@ -8,14 +8,14 @@ from langbot_plugin.entities.io.actions.enums import ActionType
|
||||
class LangBotToBoxAction(ActionType):
|
||||
"""Actions sent from LangBot to the Box runtime."""
|
||||
|
||||
HEALTH = "box_health"
|
||||
STATUS = "box_status"
|
||||
EXEC = "box_exec"
|
||||
CREATE_SESSION = "box_create_session"
|
||||
GET_SESSION = "box_get_session"
|
||||
GET_SESSIONS = "box_get_sessions"
|
||||
DELETE_SESSION = "box_delete_session"
|
||||
START_MANAGED_PROCESS = "box_start_managed_process"
|
||||
GET_MANAGED_PROCESS = "box_get_managed_process"
|
||||
GET_BACKEND_INFO = "box_get_backend_info"
|
||||
SHUTDOWN = "box_shutdown"
|
||||
HEALTH = 'box_health'
|
||||
STATUS = 'box_status'
|
||||
EXEC = 'box_exec'
|
||||
CREATE_SESSION = 'box_create_session'
|
||||
GET_SESSION = 'box_get_session'
|
||||
GET_SESSIONS = 'box_get_sessions'
|
||||
DELETE_SESSION = 'box_delete_session'
|
||||
START_MANAGED_PROCESS = 'box_start_managed_process'
|
||||
GET_MANAGED_PROCESS = 'box_get_managed_process'
|
||||
GET_BACKEND_INFO = 'box_get_backend_info'
|
||||
SHUTDOWN = 'box_shutdown'
|
||||
|
||||
@@ -11,7 +11,15 @@ import shutil
|
||||
import uuid
|
||||
|
||||
from .errors import BoxError
|
||||
from .models import DEFAULT_BOX_MOUNT_PATH, BoxExecutionResult, BoxExecutionStatus, BoxHostMountMode, BoxNetworkMode, BoxSessionInfo, BoxSpec
|
||||
from .models import (
|
||||
DEFAULT_BOX_MOUNT_PATH,
|
||||
BoxExecutionResult,
|
||||
BoxExecutionStatus,
|
||||
BoxHostMountMode,
|
||||
BoxNetworkMode,
|
||||
BoxSessionInfo,
|
||||
BoxSpec,
|
||||
)
|
||||
from .security import validate_sandbox_security
|
||||
|
||||
# Hard cap on raw subprocess output to prevent unbounded memory usage.
|
||||
@@ -213,8 +221,15 @@ class CLISandboxBackend(BaseSandboxBackend):
|
||||
older versions) are also removed.
|
||||
"""
|
||||
result = await self._run_command(
|
||||
[self.command, 'ps', '-a', '--filter', 'label=langbot.box=true',
|
||||
'--format', '{{.ID}}\t{{.Label "langbot.box.instance_id"}}'],
|
||||
[
|
||||
self.command,
|
||||
'ps',
|
||||
'-a',
|
||||
'--filter',
|
||||
'label=langbot.box=true',
|
||||
'--format',
|
||||
'{{.ID}}\t{{.Label "langbot.box.instance_id"}}',
|
||||
],
|
||||
timeout_sec=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -82,6 +82,7 @@ def _translate_action_error(exc: Exception) -> BoxError:
|
||||
BoxSessionNotFoundError,
|
||||
BoxValidationError,
|
||||
)
|
||||
|
||||
msg = str(exc)
|
||||
_ERROR_PREFIX_MAP: list[tuple[str, type[BoxError]]] = [
|
||||
('BoxValidationError:', BoxValidationError),
|
||||
@@ -182,10 +183,10 @@ class ActionRPCBoxClient(BoxRuntimeClient):
|
||||
base = ws_relay_base_url
|
||||
if base.startswith('https://'):
|
||||
scheme = 'wss://'
|
||||
suffix = base[len('https://'):]
|
||||
suffix = base[len('https://') :]
|
||||
elif base.startswith('http://'):
|
||||
scheme = 'ws://'
|
||||
suffix = base[len('http://'):]
|
||||
suffix = base[len('http://') :]
|
||||
else:
|
||||
scheme = 'ws://'
|
||||
suffix = base
|
||||
|
||||
@@ -46,7 +46,10 @@ class BoxRuntimeConnector:
|
||||
await self._connect_remote_ws()
|
||||
|
||||
def _make_connection_callback(
|
||||
self, transport_name: str, connected: asyncio.Event, connect_error: list[Exception],
|
||||
self,
|
||||
transport_name: str,
|
||||
connected: asyncio.Event,
|
||||
connect_error: list[Exception],
|
||||
):
|
||||
async def new_connection_callback(connection: Connection) -> None:
|
||||
handler = Handler(connection)
|
||||
|
||||
@@ -174,9 +174,7 @@ class BoxRuntime:
|
||||
raise BoxSessionNotFoundError(f'session {session_id} not found')
|
||||
result = self._session_to_dict(runtime_session.info)
|
||||
if runtime_session.managed_process is not None:
|
||||
result['managed_process'] = self._managed_process_to_dict(
|
||||
session_id, runtime_session.managed_process
|
||||
)
|
||||
result['managed_process'] = self._managed_process_to_dict(session_id, runtime_session.managed_process)
|
||||
return result
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
@@ -281,8 +279,14 @@ class BoxRuntime:
|
||||
|
||||
def _assert_session_compatible(self, session: BoxSessionInfo, spec: BoxSpec):
|
||||
_COMPAT_FIELDS = (
|
||||
'network', 'image', 'host_path', 'host_path_mode',
|
||||
'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs',
|
||||
'network',
|
||||
'image',
|
||||
'host_path',
|
||||
'host_path_mode',
|
||||
'cpus',
|
||||
'memory_mb',
|
||||
'pids_limit',
|
||||
'read_only_rootfs',
|
||||
)
|
||||
for field in _COMPAT_FIELDS:
|
||||
session_val = getattr(session, field)
|
||||
@@ -308,7 +312,10 @@ class BoxRuntime:
|
||||
continue
|
||||
managed_process.stderr_chunks.append(text)
|
||||
managed_process.stderr_total_len += len(text) + 1 # +1 for '\n' separator
|
||||
while managed_process.stderr_total_len > _MANAGED_PROCESS_STDERR_PREVIEW_LIMIT and managed_process.stderr_chunks:
|
||||
while (
|
||||
managed_process.stderr_total_len > _MANAGED_PROCESS_STDERR_PREVIEW_LIMIT
|
||||
and managed_process.stderr_chunks
|
||||
):
|
||||
removed = managed_process.stderr_chunks.popleft()
|
||||
managed_process.stderr_total_len -= len(removed) + 1
|
||||
self.logger.info(f'LangBot Box managed process stderr: session_id={session_id} {text}')
|
||||
@@ -322,10 +329,7 @@ class BoxRuntime:
|
||||
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}'
|
||||
)
|
||||
self.logger.info(f'LangBot Box managed process exited: session_id={session_id} return_code={return_code}')
|
||||
|
||||
async def _terminate_managed_process(self, runtime_session: _RuntimeSession) -> None:
|
||||
managed_process = runtime_session.managed_process
|
||||
|
||||
@@ -5,20 +5,22 @@ 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',
|
||||
})
|
||||
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',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_sandbox_security(spec: BoxSpec) -> None:
|
||||
@@ -30,6 +32,4 @@ def validate_sandbox_security(spec: BoxSpec) -> None:
|
||||
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'
|
||||
)
|
||||
raise BoxValidationError(f'host_path {spec.host_path} is blocked for security')
|
||||
|
||||
@@ -51,7 +51,6 @@ class BoxServerHandler(Handler):
|
||||
self._register_actions()
|
||||
|
||||
def _register_actions(self) -> None:
|
||||
|
||||
@self.action(CommonAction.PING)
|
||||
async def ping(data: dict[str, Any]) -> ActionResponse:
|
||||
return ActionResponse.success({})
|
||||
@@ -109,9 +108,7 @@ class BoxServerHandler(Handler):
|
||||
|
||||
@self.action(LangBotToBoxAction.GET_MANAGED_PROCESS)
|
||||
async def get_managed_process(data: dict[str, Any]) -> ActionResponse:
|
||||
return ActionResponse.success(
|
||||
self._runtime.get_managed_process(data['session_id'])
|
||||
)
|
||||
return ActionResponse.success(self._runtime.get_managed_process(data['session_id']))
|
||||
|
||||
@self.action(LangBotToBoxAction.GET_BACKEND_INFO)
|
||||
async def get_backend_info(data: dict[str, Any]) -> ActionResponse:
|
||||
@@ -146,7 +143,9 @@ async def handle_managed_process_ws(request: web.Request) -> web.StreamResponse:
|
||||
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'))
|
||||
return _error_response(
|
||||
BoxManagedProcessConflictError(f'managed process in session {session_id} is not running')
|
||||
)
|
||||
|
||||
ws = web.WebSocketResponse(protocols=('mcp',))
|
||||
await ws.prepare(request)
|
||||
@@ -173,7 +172,12 @@ async def handle_managed_process_ws(request: web.Request) -> web.StreamResponse:
|
||||
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):
|
||||
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())
|
||||
@@ -229,10 +233,12 @@ async def _run_server(host: str, port: int, mode: str) -> None:
|
||||
try:
|
||||
if mode == 'stdio':
|
||||
from langbot_plugin.runtime.io.controllers.stdio.server import StdioServerController
|
||||
|
||||
ctrl = StdioServerController()
|
||||
await ctrl.run(new_connection_callback)
|
||||
else:
|
||||
from langbot_plugin.runtime.io.controllers.ws.server import WebSocketServerController
|
||||
|
||||
# Action RPC uses port+1 to avoid conflict with ws relay
|
||||
rpc_port = port + 1
|
||||
logger.info(f'Box action RPC (ws) listening on {host}:{rpc_port}')
|
||||
@@ -248,8 +254,9 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='LangBot Box Runtime Service')
|
||||
parser.add_argument('--host', default='0.0.0.0', help='Bind address')
|
||||
parser.add_argument('--port', type=int, default=5410, help='Bind port (ws relay)')
|
||||
parser.add_argument('--mode', choices=['stdio', 'ws'], default='stdio',
|
||||
help='Control channel transport (default: stdio)')
|
||||
parser.add_argument(
|
||||
'--mode', choices=['stdio', 'ws'], default='stdio', help='Control channel transport (default: stdio)'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||
|
||||
@@ -31,6 +31,7 @@ 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}')
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core import app as core_app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -66,9 +67,7 @@ class BoxService:
|
||||
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.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
@@ -109,11 +108,7 @@ class BoxService:
|
||||
if self._runtime_connector is not None:
|
||||
self._runtime_connector.dispose()
|
||||
loop = getattr(self.ap, 'event_loop', None)
|
||||
if (
|
||||
loop is not None
|
||||
and not loop.is_closed()
|
||||
and (self._shutdown_task is None or self._shutdown_task.done())
|
||||
):
|
||||
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
|
||||
self._shutdown_task = loop.create_task(self.shutdown())
|
||||
|
||||
async def get_sessions(self) -> list[dict]:
|
||||
@@ -295,7 +290,9 @@ class BoxService:
|
||||
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')
|
||||
raise BoxValidationError(
|
||||
'host_path mounting is disabled because no allowed_host_mount_roots are configured'
|
||||
)
|
||||
|
||||
for allowed_root in self.allowed_host_mount_roots:
|
||||
if _is_path_under(host_path, allowed_root):
|
||||
@@ -317,8 +314,14 @@ class BoxService:
|
||||
"""Merge profile defaults into *params* in-place, enforce locked fields and clamp timeout."""
|
||||
profile = self.profile
|
||||
_PROFILE_FIELDS = (
|
||||
'image', 'network', 'timeout_sec', 'host_path_mode',
|
||||
'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs',
|
||||
'image',
|
||||
'network',
|
||||
'timeout_sec',
|
||||
'host_path_mode',
|
||||
'cpus',
|
||||
'memory_mb',
|
||||
'pids_limit',
|
||||
'read_only_rootfs',
|
||||
)
|
||||
|
||||
for field in _PROFILE_FIELDS:
|
||||
@@ -342,12 +345,14 @@ class BoxService:
|
||||
# ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
def _record_error(self, exc: Exception, query: 'pipeline_query.Query'):
|
||||
self._recent_errors.append({
|
||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||
'type': type(exc).__name__,
|
||||
'message': str(exc),
|
||||
'query_id': str(query.query_id),
|
||||
})
|
||||
self._recent_errors.append(
|
||||
{
|
||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||
'type': type(exc).__name__,
|
||||
'message': str(exc),
|
||||
'query_id': str(query.query_id),
|
||||
}
|
||||
)
|
||||
|
||||
def get_recent_errors(self) -> list[dict]:
|
||||
return list(self._recent_errors)
|
||||
|
||||
@@ -508,7 +508,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
def dispose(self):
|
||||
# On non-Windows stdio mode, terminate via the controller's process handle.
|
||||
# On Windows, the managed subprocess is cleaned up by the base class.
|
||||
if self.is_enable_plugin and hasattr(self, 'ctrl') and isinstance(self.ctrl, stdio_client_controller.StdioClientController):
|
||||
if (
|
||||
self.is_enable_plugin
|
||||
and hasattr(self, 'ctrl')
|
||||
and isinstance(self.ctrl, stdio_client_controller.StdioClientController)
|
||||
):
|
||||
self.ap.logger.info('Terminating plugin runtime process...')
|
||||
self.ctrl.process.terminate()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class MCPSessionStatus(enum.Enum):
|
||||
|
||||
class MCPSessionErrorPhase(enum.Enum):
|
||||
"""Which phase of the MCP lifecycle failed."""
|
||||
|
||||
SESSION_CREATE = 'session_create'
|
||||
DEP_INSTALL = 'dep_install'
|
||||
PROCESS_START = 'process_start'
|
||||
@@ -115,9 +116,7 @@ class RuntimeMCPSession:
|
||||
self._ready_event = asyncio.Event()
|
||||
|
||||
# Parse box config once
|
||||
self.box_config = MCPServerBoxConfig.model_validate(
|
||||
server_config.get('box', {})
|
||||
)
|
||||
self.box_config = MCPServerBoxConfig.model_validate(server_config.get('box', {}))
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
if self._uses_box_stdio():
|
||||
@@ -159,8 +158,7 @@ class RuntimeMCPSession:
|
||||
install_cmd = self._detect_install_command(host_path)
|
||||
if install_cmd:
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: installing dependencies in Box '
|
||||
f'with: {install_cmd}'
|
||||
f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}'
|
||||
)
|
||||
exec_payload = dict(session_payload)
|
||||
exec_payload['cmd'] = install_cmd
|
||||
@@ -175,10 +173,7 @@ class RuntimeMCPSession:
|
||||
if not result.ok:
|
||||
self.error_phase = MCPSessionErrorPhase.DEP_INSTALL
|
||||
stderr_preview = (result.stderr or '')[:500]
|
||||
raise Exception(
|
||||
f'Dependency install failed (exit code {result.exit_code}): '
|
||||
f'{stderr_preview}'
|
||||
)
|
||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||
|
||||
# Phase: managed process start
|
||||
try:
|
||||
@@ -318,8 +313,7 @@ class RuntimeMCPSession:
|
||||
return
|
||||
delay = self._RETRY_DELAYS[attempt]
|
||||
self.ap.logger.warning(
|
||||
f'MCP session {self.server_name} failed (attempt {attempt + 1}), '
|
||||
f'retrying in {delay}s: {e}'
|
||||
f'MCP session {self.server_name} failed (attempt {attempt + 1}), retrying in {delay}s: {e}'
|
||||
)
|
||||
await self._cleanup_box_stdio_session()
|
||||
# Reset status for retry
|
||||
@@ -493,7 +487,7 @@ class RuntimeMCPSession:
|
||||
return path
|
||||
normalized_host = os.path.realpath(host_path)
|
||||
if path.startswith(normalized_host + '/'):
|
||||
return '/workspace' + path[len(normalized_host):]
|
||||
return '/workspace' + path[len(normalized_host) :]
|
||||
if path == normalized_host:
|
||||
return '/workspace'
|
||||
return path
|
||||
@@ -537,7 +531,7 @@ class RuntimeMCPSession:
|
||||
venv_dir = parts[i - 1]
|
||||
if venv_dir in _VENV_DIRS:
|
||||
# Return everything before the venv directory
|
||||
project_root = '/'.join(parts[:i - 1])
|
||||
project_root = '/'.join(parts[: i - 1])
|
||||
return project_root if project_root else '/'
|
||||
return directory
|
||||
|
||||
@@ -629,13 +623,10 @@ class RuntimeMCPSession:
|
||||
if not command.startswith(normalized_host + '/'):
|
||||
return command
|
||||
# Check if command is a venv python interpreter
|
||||
rel = command[len(normalized_host) + 1:] # e.g. ".venv/bin/python"
|
||||
rel = command[len(normalized_host) + 1 :] # e.g. ".venv/bin/python"
|
||||
parts = rel.replace('\\', '/').split('/')
|
||||
# Match patterns like .venv/bin/python*, venv/bin/python*, etc.
|
||||
if (len(parts) >= 3
|
||||
and parts[0] in _VENV_DIRS
|
||||
and parts[1] in _VENV_BIN_DIRS
|
||||
and parts[2].startswith('python')):
|
||||
if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'):
|
||||
return 'python'
|
||||
# Not a venv python — do normal path rewrite
|
||||
return self._rewrite_path(command, host_path)
|
||||
|
||||
@@ -12,7 +12,6 @@ SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec'
|
||||
|
||||
|
||||
class NativeToolLoader(loader.ToolLoader):
|
||||
|
||||
def __init__(self, ap):
|
||||
super().__init__(ap)
|
||||
self._sandbox_exec_tool: resource_tool.LLMTool | None = None
|
||||
|
||||
@@ -63,8 +63,7 @@ class ManagedRuntimeConnector:
|
||||
# Fast-fail if the process already died.
|
||||
if self.runtime_subprocess is not None and self.runtime_subprocess.returncode is not None:
|
||||
raise RuntimeError(
|
||||
f'local {runtime_name} exited before becoming ready '
|
||||
f'(code {self.runtime_subprocess.returncode})'
|
||||
f'local {runtime_name} exited before becoming ready (code {self.runtime_subprocess.returncode})'
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user