fix: ruff

This commit is contained in:
youhuanghe
2026-03-22 03:40:24 +00:00
committed by WangCham
parent 76fbd08680
commit a7664d1665
12 changed files with 119 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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