diff --git a/src/langbot/pkg/box/actions.py b/src/langbot/pkg/box/actions.py index 54ebb7b0..954c606c 100644 --- a/src/langbot/pkg/box/actions.py +++ b/src/langbot/pkg/box/actions.py @@ -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' diff --git a/src/langbot/pkg/box/backend.py b/src/langbot/pkg/box/backend.py index 75ea03a8..e5bbe564 100644 --- a/src/langbot/pkg/box/backend.py +++ b/src/langbot/pkg/box/backend.py @@ -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, ) diff --git a/src/langbot/pkg/box/client.py b/src/langbot/pkg/box/client.py index 964b451b..b2732b37 100644 --- a/src/langbot/pkg/box/client.py +++ b/src/langbot/pkg/box/client.py @@ -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 diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index c17476b4..389f56c4 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -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) diff --git a/src/langbot/pkg/box/runtime.py b/src/langbot/pkg/box/runtime.py index 52164d44..36f8c134 100644 --- a/src/langbot/pkg/box/runtime.py +++ b/src/langbot/pkg/box/runtime.py @@ -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 diff --git a/src/langbot/pkg/box/security.py b/src/langbot/pkg/box/security.py index 1c05a039..d5a8c513 100644 --- a/src/langbot/pkg/box/security.py +++ b/src/langbot/pkg/box/security.py @@ -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') diff --git a/src/langbot/pkg/box/server.py b/src/langbot/pkg/box/server.py index 4af6de6d..8640b5e9 100644 --- a/src/langbot/pkg/box/server.py +++ b/src/langbot/pkg/box/server.py @@ -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) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index bb8d7dbc..48e1fcbf 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -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) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index a02037ec..69afde77 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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() diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 88e76323..76ff5017 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -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) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index d13533e4..fdf74f40 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -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 diff --git a/src/langbot/pkg/utils/managed_runtime.py b/src/langbot/pkg/utils/managed_runtime.py index 50f90df3..77f59be4 100644 --- a/src/langbot/pkg/utils/managed_runtime.py +++ b/src/langbot/pkg/utils/managed_runtime.py @@ -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: