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
+59 -14
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
import sys import sys
import typing 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.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxRuntimeUnavailableError from langbot_plugin.box.errors import BoxRuntimeUnavailableError
from langbot_plugin.box.actions import LangBotToBoxAction
from ..utils import platform from ..utils import platform
from ..utils.managed_runtime import ManagedRuntimeConnector from ..utils.managed_runtime import ManagedRuntimeConnector
@@ -27,6 +29,10 @@ _DEFAULT_PORT = 5410
_HEARTBEAT_INTERVAL_SEC = 20 _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: def _get_box_config(ap) -> dict:
"""Return the 'box' section from instance config, with safe fallbacks.""" """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', {}) 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: def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
"""Derive the WS relay base URL used for managed-process attach. """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) box_cfg = _get_box_config(ap)
# Explicit relay URL takes precedence. # Explicit runtime endpoint takes precedence. The config value is a base
runtime_url = str(box_cfg.get('runtime_url', '')).strip() # URL; endpoint-specific paths are appended by the SDK client.
if runtime_url: endpoint = _get_runtime_endpoint(box_cfg)
return runtime_url 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. # In Docker, relay lives on the box runtime container.
if platform.get_platform() == 'docker': if platform.get_platform() == 'docker':
@@ -59,7 +83,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Connect to the Box runtime via action RPC. """Connect to the Box runtime via action RPC.
Transport decision (mirrors Plugin runtime logic): 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) 2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe)
3. Unix / macOS -> subprocess + stdio pipe 3. Unix / macOS -> subprocess + stdio pipe
""" """
@@ -74,7 +98,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
): ):
super().__init__(ap) super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback 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.ws_relay_base_url = resolve_box_ws_relay_url(ap)
self.client = ActionRPCBoxClient(logger=ap.logger) self.client = ActionRPCBoxClient(logger=ap.logger)
@@ -87,6 +111,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
parsed = urlparse(self.ws_relay_base_url) parsed = urlparse(self.ws_relay_base_url)
self._relay_host = parsed.hostname or '127.0.0.1' self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_PORT 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: def _uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime. """Whether the connector should use WebSocket to reach the Box runtime.
@@ -94,17 +119,17 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
True when: True when:
- Running inside Docker (Box runtime is a separate container) - Running inside Docker (Box runtime is a separate container)
- The ``--standalone-box`` CLI flag was passed - The ``--standalone-box`` CLI flag was passed
- An explicit ``runtime_url`` was configured - An explicit ``runtime.endpoint`` was configured
""" """
return bool( return bool(
self.configured_runtime_url self.configured_runtime_endpoint
or platform.get_platform() == 'docker' or platform.get_platform() == 'docker'
or platform.use_websocket_to_connect_box_runtime() or platform.use_websocket_to_connect_box_runtime()
) )
async def initialize(self) -> None: async def initialize(self) -> None:
if self._uses_websocket(): 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() await self._start_subprocess_then_ws()
else: else:
await self._connect_remote_ws() await self._connect_remote_ws()
@@ -141,6 +166,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self.ap.logger.info('Use stdio to connect to box runtime') self.ap.logger.info('Use stdio to connect to box runtime')
python_path = sys.executable python_path = sys.executable
env = os.environ.copy() env = os.environ.copy()
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
connected = asyncio.Event() connected = asyncio.Event()
connect_error: list[Exception] = [] connect_error: list[Exception] = []
@@ -168,12 +195,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows).""" """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') 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', '-m',
'langbot_plugin.box.server', 'langbot_plugin.box.server',
'--ws-control-port', '--ws-control-port',
str(self._relay_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' ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
await self._connect_ws(ws_url, '(windows) WebSocket') 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``. All endpoints share a single port; action RPC is at ``/rpc/ws``.
""" """
if self.configured_runtime_url: if self.configured_runtime_endpoint:
return self.configured_runtime_url 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': if platform.get_platform() == 'docker':
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws' 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()) self._handler_task = asyncio.create_task(handler.run())
try: try:
await handler.call_action(CommonAction.PING, {}) 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}.') self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
connected.set() connected.set()
await self._handler_task await self._handler_task
@@ -292,5 +337,5 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
# -- config helpers ------------------------------------------------------ # -- config helpers ------------------------------------------------------
def _load_configured_runtime_url(self) -> str: def _load_configured_runtime_endpoint(self) -> str:
return str(_get_box_config(self.ap).get('runtime_url', '')).strip() return _get_runtime_endpoint(_get_box_config(self.ap))
+50 -54
View File
@@ -28,11 +28,6 @@ _MAX_RECENT_ERRORS = 50
_MIB = 1024 * 1024 _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: def _is_path_under(path: str, root: str) -> bool:
"""Check whether *path* equals *root* or is a child of *root*.""" """Check whether *path* equals *root* or is a child of *root*."""
return path == root or path.startswith(f'{root}{os.sep}') return path == root or path.startswith(f'{root}{os.sep}')
@@ -57,9 +52,9 @@ class BoxService:
client = self._runtime_connector.client client = self._runtime_connector.client
self.client = client self.client = client
self.output_limit_chars = output_limit_chars self.output_limit_chars = output_limit_chars
self.shared_host_root = self._load_shared_host_root() self.host_root = self._load_host_root()
self.allowed_host_mount_roots = self._load_allowed_host_mount_roots() self.allowed_mount_roots = self._load_allowed_mount_roots()
self.default_host_workspace = self._load_default_host_workspace() self.default_workspace = self._load_default_workspace()
self.profile = self._load_profile() self.profile = self._load_profile()
self.custom_image = self._load_custom_image() self.custom_image = self._load_custom_image()
self.workspace_quota_mb = self._load_workspace_quota_mb() self.workspace_quota_mb = self._load_workspace_quota_mb()
@@ -70,7 +65,7 @@ class BoxService:
self._reconnecting = False self._reconnecting = False
async def initialize(self): async def initialize(self):
self._ensure_default_host_workspace() self._ensure_default_workspace()
try: try:
if self._runtime_connector is not None: if self._runtime_connector is not None:
await self._runtime_connector.initialize() await self._runtime_connector.initialize()
@@ -80,7 +75,7 @@ class BoxService:
self._connector_error = '' self._connector_error = ''
self.ap.logger.info( self.ap.logger.info(
f'LangBot Box runtime initialized: profile={self.profile.name} ' 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: 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}')
@@ -134,7 +129,7 @@ class BoxService:
skip_host_mount_validation: bool = False, skip_host_mount_validation: bool = False,
) -> dict: ) -> dict:
if not self._available: 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: try:
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
except BoxError as exc: except BoxError as exc:
@@ -251,8 +246,8 @@ class BoxService:
def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec:
spec_payload = dict(spec_payload) spec_payload = dict(spec_payload)
spec_payload.setdefault('env', {}) spec_payload.setdefault('env', {})
if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None: if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None:
spec_payload['host_path'] = self.default_host_workspace spec_payload['host_path'] = self.default_workspace
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None: 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 spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
@@ -280,10 +275,10 @@ class BoxService:
process_spec = BoxManagedProcessSpec.model_validate(process_payload) process_spec = BoxManagedProcessSpec.model_validate(process_payload)
return await self.client.start_managed_process(session_id, process_spec) return await self.client.start_managed_process(session_id, process_spec)
async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo: async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
return await self.client.get_managed_process(session_id) 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) getter = getattr(self.client, 'get_managed_process_websocket_url', None)
if getter is None: if getter is None:
raise BoxValidationError('box runtime client does not support managed process websocket attach') 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 if self._runtime_connector is not None
else 'http://127.0.0.1:5410' 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: def _serialize_result(self, result: BoxExecutionResult) -> dict:
stdout, stdout_truncated = self._truncate(result.stdout) stdout, stdout_truncated = self._truncate(result.stdout)
@@ -382,8 +377,11 @@ class BoxService:
'stderr_preview': stderr_preview, 'stderr_preview': stderr_preview,
} }
def _load_allowed_host_mount_roots(self) -> list[str]: def _local_config(self) -> dict:
configured_roots = _get_box_config(self.ap).get('allowed_host_mount_roots', []) 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] = [] normalized_roots: list[str] = []
for root in configured_roots: for root in configured_roots:
@@ -392,31 +390,31 @@ class BoxService:
continue continue
normalized_roots.append(os.path.realpath(os.path.abspath(root_value))) normalized_roots.append(os.path.realpath(os.path.abspath(root_value)))
if not normalized_roots and self.shared_host_root is not None: if not normalized_roots and self.host_root is not None:
normalized_roots.append(self.shared_host_root) normalized_roots.append(self.host_root)
return normalized_roots return normalized_roots
def _load_shared_host_root(self) -> str | None: def _load_host_root(self) -> str | None:
shared_host_root = str(_get_box_config(self.ap).get('shared_host_root', '')).strip() host_root = str(self._local_config().get('host_root', '')).strip()
if not shared_host_root: if not host_root:
return None 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: def _load_default_workspace(self) -> str | None:
default_host_workspace = str(_get_box_config(self.ap).get('default_host_workspace', '')).strip() default_workspace = str(self._local_config().get('default_workspace', '')).strip()
if not default_host_workspace: if not default_workspace:
if self.shared_host_root is None: if self.host_root is None:
return None return None
default_host_workspace = os.path.join(self.shared_host_root, 'default') default_workspace = os.path.join(self.host_root, 'default')
return os.path.realpath(os.path.abspath(default_host_workspace)) return os.path.realpath(os.path.abspath(default_workspace))
def _load_custom_image(self) -> str | None: 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 return raw or None
def _load_workspace_quota_mb(self) -> int | 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, ''): if raw_value in (None, ''):
return None return None
try: try:
@@ -427,28 +425,28 @@ class BoxService:
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0') raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
return value return value
def _ensure_default_host_workspace(self): def _ensure_default_workspace(self):
if self.default_host_workspace is None: if self.default_workspace is None:
return return
if os.path.isdir(self.default_host_workspace): if os.path.isdir(self.default_workspace):
return return
if os.path.exists(self.default_host_workspace): if os.path.exists(self.default_workspace):
raise BoxValidationError('default_host_workspace must point to a directory on the host') 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( 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: for allowed_root in self.allowed_mount_roots:
if _is_path_under(self.default_host_workspace, allowed_root): if _is_path_under(self.default_workspace, allowed_root):
os.makedirs(self.default_host_workspace, exist_ok=True) os.makedirs(self.default_workspace, exist_ok=True)
return return
allowed_roots = ', '.join(self.allowed_host_mount_roots) allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'default_host_workspace is outside allowed_host_mount_roots: {allowed_roots}') raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
def _validate_host_mount(self, spec: BoxSpec): def _validate_host_mount(self, spec: BoxSpec):
if spec.host_path is None: if spec.host_path is None:
@@ -458,20 +456,18 @@ class BoxService:
if not os.path.isdir(host_path): if not os.path.isdir(host_path):
raise BoxValidationError('host_path must point to an existing directory on the host') raise BoxValidationError('host_path must point to an existing directory on the host')
if not self.allowed_host_mount_roots: if not self.allowed_mount_roots:
raise BoxValidationError( raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured')
'host_path mounting is disabled because no allowed_host_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): if _is_path_under(host_path, allowed_root):
return return
allowed_roots = ', '.join(self.allowed_host_mount_roots) allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'host_path is outside allowed_host_mount_roots: {allowed_roots}') raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}')
def _load_profile(self) -> BoxProfile: 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) profile = BUILTIN_PROFILES.get(profile_name)
if profile is None: 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 ' '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.' '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 += ( guidance += (
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' ' 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 ' 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
+20 -14
View File
@@ -1,4 +1,3 @@
from __future__ import annotations
"""Reusable workspace/session helpers built on top of Box. """Reusable workspace/session helpers built on top of Box.
This module is the middle layer between the raw Box runtime primitives and 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 - MCP stdio chooses how to prepare dependencies and attaches to a managed process
""" """
from __future__ import annotations
import os import os
import textwrap import textwrap
from typing import Any 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: if not host_path or not path:
return path return path
normalized_host = os.path.realpath(host_path) normalized_host = os.path.realpath(host_path)
if path.startswith(normalized_host + '/'): normalized_path = os.path.realpath(path)
return mount_path + path[len(normalized_host) :] if normalized_path.startswith(normalized_host + '/'):
if path == normalized_host: return mount_path + normalized_path[len(normalized_host) :]
if normalized_path == normalized_host:
return mount_path return mount_path
return 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: if not host_path or not command:
return command return command
normalized_host = os.path.realpath(host_path) 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 return command
rel = command[len(normalized_host) + 1 :] rel = normalized_command[len(normalized_host) + 1 :]
parts = rel.replace('\\', '/').split('/') 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'): 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 '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]: def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path) normalized_root = normalize_host_path(host_path)
if not normalized_root: if not normalized_root:
return [] return []
return [ return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
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: def classify_python_workspace(host_path: str | None) -> str | None:
@@ -269,6 +270,7 @@ class BoxWorkspaceSession:
cpus: float | None = None, cpus: float | None = None,
memory_mb: int | None = None, memory_mb: int | None = None,
pids_limit: int | None = None, pids_limit: int | None = None,
persistent: bool = False,
): ):
self.box_service = box_service self.box_service = box_service
self.session_id = session_id self.session_id = session_id
@@ -283,6 +285,7 @@ class BoxWorkspaceSession:
self.cpus = cpus self.cpus = cpus
self.memory_mb = memory_mb self.memory_mb = memory_mb
self.pids_limit = pids_limit self.pids_limit = pids_limit
self.persistent = persistent
def rewrite_path(self, path: str) -> str: def rewrite_path(self, path: str) -> str:
return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path) return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path)
@@ -297,6 +300,7 @@ class BoxWorkspaceSession:
'session_id': self.session_id, 'session_id': self.session_id,
'workdir': self.workdir, 'workdir': self.workdir,
'env': self.env, 'env': self.env,
'persistent': self.persistent,
} }
if self.network is not None: if self.network is not None:
payload['network'] = self.network payload['network'] = self.network
@@ -388,17 +392,19 @@ class BoxWorkspaceSession:
command: str, command: str,
args: list[str] | None = None, args: list[str] | None = None,
*, *,
process_id: str = 'default',
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
cwd: str = '/workspace', cwd: str = '/workspace',
): ):
payload = self.build_process_payload(command, args, env=env, cwd=cwd) 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) return await self.box_service.start_managed_process(self.session_id, payload)
async def get_managed_process(self): async def get_managed_process(self, process_id: str = 'default'):
return await self.box_service.get_managed_process(self.session_id) return await self.box_service.get_managed_process(self.session_id, process_id)
def get_managed_process_websocket_url(self) -> str: def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id) return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
async def cleanup(self) -> None: async def cleanup(self) -> None:
await self.box_service.client.delete_session(self.session_id) await self.box_service.client.delete_session(self.session_id)
@@ -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.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp 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): class MCPSessionStatus(enum.Enum):
@@ -349,7 +349,7 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio() return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str: 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: def _rewrite_path(self, path: str, host_path: str | None) -> str:
return self._box_stdio_runtime.rewrite_path(path, host_path) return self._box_stdio_runtime.rewrite_path(path, host_path)
@@ -81,8 +81,14 @@ class BoxStdioSessionRuntime:
cpus=self.config.cpus, cpus=self.config.cpus,
memory_mb=self.config.memory_mb, memory_mb=self.config.memory_mb,
pids_limit=self.config.pids_limit, 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: def uses_box_stdio(self) -> bool:
if self.server_config.get('mode') != 'stdio': if self.server_config.get('mode') != 'stdio':
return False return False
@@ -104,7 +110,9 @@ class BoxStdioSessionRuntime:
if host_path: if host_path:
install_cmd = self.owner._detect_install_command(host_path) install_cmd = self.owner._detect_install_command(host_path)
if install_cmd: 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: try:
result = await workspace.execute_raw( result = await workspace.execute_raw(
install_cmd, install_cmd,
@@ -122,6 +130,7 @@ class BoxStdioSessionRuntime:
await workspace.start_managed_process( await workspace.start_managed_process(
self.server_config['command'], self.server_config['command'],
self.server_config.get('args', []), self.server_config.get('args', []),
process_id=self.process_id,
env=self.server_config.get('env', {}), env=self.server_config.get('env', {}),
) )
except Exception: except Exception:
@@ -129,10 +138,12 @@ class BoxStdioSessionRuntime:
raise raise
try: 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)) transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport 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: except Exception:
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
raise raise
@@ -150,7 +161,7 @@ class BoxStdioSessionRuntime:
consecutive_errors = 0 consecutive_errors = 0
while not self.owner._shutdown_event.is_set(): while not self.owner._shutdown_event.is_set():
try: try:
info = await workspace.get_managed_process() info = await workspace.get_managed_process(self.process_id)
if isinstance(info, dict): if isinstance(info, dict):
status = info.get('status', '') status = info.get('status', '')
else: else:
@@ -173,10 +184,13 @@ class BoxStdioSessionRuntime:
if not self.uses_box_stdio(): if not self.uses_box_stdio():
return return
try: # In the shared-session model, we do not delete the session itself.
await self._build_workspace().cleanup() # The managed process exits independently; deleting the session would
except Exception as exc: # kill other MCP servers sharing the same container.
self.ap.logger.warning(f'Failed to cleanup Box session for MCP server {self.server_name}: {exc}') 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: def rewrite_path(self, path: str, host_path: str | None) -> str:
return rewrite_mounted_path(path, host_path) return rewrite_mounted_path(path, host_path)
@@ -121,9 +121,7 @@ class NativeToolLoader(loader.ToolLoader):
) )
box_service = self.ap.box_service box_service = self.ap.box_service
host_root = ( host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace
)
if not host_root: if not host_root:
raise ValueError('No host workspace configured for file operations.') raise ValueError('No host workspace configured for file operations.')
@@ -183,9 +183,9 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
def _resolve_workspace_directory(self, sandbox_path: str) -> str: def _resolve_workspace_directory(self, sandbox_path: str) -> str:
box_service = getattr(self.ap, 'box_service', None) 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: 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' normalized_path = str(sandbox_path).strip() or '/workspace'
if not normalized_path.startswith('/workspace'): if not normalized_path.startswith('/workspace'):
+16 -8
View File
@@ -105,14 +105,22 @@ monitoring:
# Number of expired rows to delete per table batch # Number of expired rows to delete per table batch
delete_batch_size: 1000 delete_batch_size: 1000
box: box:
profile: 'default' backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence.
image: '' # Custom sandbox container image. Leave empty to use the profile default (python:3.11-slim). runtime:
runtime_url: '' # Action-RPC WebSocket URL of an external Box Runtime. Leave empty for auto-detection (stdio locally, Docker service in containers). endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
shared_host_root: './data/box' # For Docker deployment, use '/workspaces' local:
default_host_workspace: '' # Defaults to '<shared_host_root>/default' profile: 'default'
allowed_host_mount_roots: # Defaults to ['<shared_host_root>'] when left empty image: '' # Custom local sandbox image. Leave empty to use the profile default.
- './data/box' host_root: './data/box' # Base host directory for local workspace mounts. For Docker deployment, use '/workspaces'.
- '/tmp' 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:
# Space service URL for OAuth and API # Space service URL for OAuth and API
url: 'https://space.langbot.app' url: 'https://space.langbot.app'
@@ -54,6 +54,7 @@
} }
], ],
"knowledge-bases": [], "knowledge-bases": [],
"box-session-id-template": "{launcher_type}_{launcher_id}",
"rerank-model": "", "rerank-model": "",
"rerank-top-k": 5 "rerank-top-k": 5
}, },
@@ -124,6 +124,83 @@ stages:
field: __system.is_wizard field: __system.is_wizard
operator: neq operator: neq
value: true 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 - name: rerank-model
label: label:
en_US: Rerank Model en_US: Rerank Model
@@ -297,9 +297,14 @@ async def test_full_service_to_remote_runtime(tmp_path):
instance_config=SimpleNamespace( instance_config=SimpleNamespace(
data={ data={
'box': { 'box': {
'profile': 'default', 'backend': 'local',
'allowed_host_mount_roots': [str(tmp_path)], 'runtime': {'endpoint': ''},
'default_host_workspace': str(host_dir), 'local': {
'profile': 'default',
'allowed_mount_roots': [str(tmp_path)],
'default_workspace': str(host_dir),
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
} }
} }
), ),
+13 -9
View File
@@ -9,16 +9,20 @@ from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box.connector import BoxRuntimeConnector from langbot.pkg.box.connector import BoxRuntimeConnector
def make_app(logger: Mock, runtime_url: str = ''): def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace( return SimpleNamespace(
logger=logger, logger=logger,
instance_config=SimpleNamespace( instance_config=SimpleNamespace(
data={ data={
'box': { 'box': {
'runtime_url': runtime_url, 'backend': 'local',
'profile': 'default', 'runtime': {'endpoint': runtime_endpoint},
'allowed_host_mount_roots': [], 'local': {
'default_host_workspace': '', 'profile': 'default',
'allowed_mount_roots': [],
'default_workspace': '',
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
} }
} }
), ),
@@ -26,7 +30,7 @@ def make_app(logger: Mock, runtime_url: str = ''):
def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch): def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch):
"""Without runtime_url, on a non-Docker Unix platform, use stdio.""" """Without runtime.endpoint, on a non-Docker Unix platform, use stdio."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock())) connector = BoxRuntimeConnector(make_app(Mock()))
@@ -36,11 +40,11 @@ def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch
def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch): def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch):
"""With an explicit runtime_url, always use WebSocket.""" """With an explicit runtime.endpoint, always use WebSocket."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
logger = Mock() logger = Mock()
connector = BoxRuntimeConnector(make_app(logger, runtime_url='http://box-runtime:5410')) connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410'))
assert connector._uses_websocket() is True assert connector._uses_websocket() is True
assert isinstance(connector.client, ActionRPCBoxClient) assert isinstance(connector.client, ActionRPCBoxClient)
@@ -76,7 +80,7 @@ def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPa
def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch): def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_url='http://box-runtime:5410')) connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
assert connector.ws_relay_base_url == 'http://box-runtime:5410' assert connector.ws_relay_base_url == 'http://box-runtime:5410'
+34 -25
View File
@@ -67,12 +67,15 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec): async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec):
return await self._runtime.start_managed_process(session_id, spec) return await self._runtime.start_managed_process(session_id, spec)
async def get_managed_process(self, session_id: str): async def get_managed_process(self, session_id: str, process_id: str = 'default'):
return self._runtime.get_managed_process(session_id) return self._runtime.get_managed_process(session_id, process_id)
async def get_session(self, session_id: str): async def get_session(self, session_id: str):
return self._runtime.get_session(session_id) return self._runtime.get_session(session_id)
async def init(self, config: dict) -> None:
self._runtime.init(config)
class FakeBackend(BaseSandboxBackend): class FakeBackend(BaseSandboxBackend):
def __init__(self, logger: Mock, available: bool = True): def __init__(self, logger: Mock, available: bool = True):
@@ -141,19 +144,24 @@ def make_query(query_id: int = 42) -> pipeline_query.Query:
def make_app( def make_app(
logger: Mock, logger: Mock,
allowed_host_mount_roots: list[str] | None = None, allowed_mount_roots: list[str] | None = None,
profile: str = 'default', profile: str = 'default',
shared_host_root: str = '', host_root: str = '',
workspace_quota_mb: int | None = None, workspace_quota_mb: int | None = None,
): ):
box_config = { box_config = {
'profile': profile, 'backend': 'local',
'shared_host_root': shared_host_root, 'runtime': {'endpoint': ''},
'allowed_host_mount_roots': allowed_host_mount_roots or [], 'local': {
'default_host_workspace': '', 'profile': profile,
'host_root': host_root,
'allowed_mount_roots': allowed_mount_roots or [],
'default_workspace': '',
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
} }
if workspace_quota_mb is not None: if workspace_quota_mb is not None:
box_config['workspace_quota_mb'] = workspace_quota_mb box_config['local']['workspace_quota_mb'] = workspace_quota_mb
return SimpleNamespace( return SimpleNamespace(
logger=logger, logger=logger,
@@ -293,14 +301,14 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tmp_path): async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_path):
logger = Mock() logger = Mock()
backend = FakeBackend(logger) backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
host_dir = tmp_path / 'default-workspace' host_dir = tmp_path / 'default-workspace'
host_dir.mkdir() host_dir.mkdir()
app = make_app(logger, [str(tmp_path)]) app = make_app(logger, [str(tmp_path)])
app.instance_config.data['box']['default_host_workspace'] = str(host_dir) app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
@@ -313,36 +321,36 @@ async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tm
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_box_service_creates_default_host_workspace_on_initialize(tmp_path): async def test_box_service_creates_default_workspace_on_initialize(tmp_path):
logger = Mock() logger = Mock()
backend = FakeBackend(logger) backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
allowed_root = tmp_path / 'allowed-root' allowed_root = tmp_path / 'allowed-root'
allowed_root.mkdir() allowed_root.mkdir()
default_host_workspace = allowed_root / 'default-workspace' default_workspace = allowed_root / 'default-workspace'
app = make_app(logger, [str(allowed_root)]) app = make_app(logger, [str(allowed_root)])
app.instance_config.data['box']['default_host_workspace'] = str(default_host_workspace) app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
assert default_host_workspace.is_dir() assert default_workspace.is_dir()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_box_service_derives_workspace_and_allowed_root_from_shared_host_root(tmp_path): async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp_path):
logger = Mock() logger = Mock()
backend = FakeBackend(logger) backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
shared_root = tmp_path / 'shared-box-root' shared_root = tmp_path / 'shared-box-root'
app = make_app(logger, shared_host_root=str(shared_root)) app = make_app(logger, host_root=str(shared_root))
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
assert service.shared_host_root == os.path.realpath(shared_root) assert service.host_root == os.path.realpath(shared_root)
assert service.default_host_workspace == os.path.realpath(shared_root / 'default') assert service.default_workspace == os.path.realpath(shared_root / 'default')
assert service.allowed_host_mount_roots == [os.path.realpath(shared_root)] assert service.allowed_mount_roots == [os.path.realpath(shared_root)]
assert (shared_root / 'default').is_dir() assert (shared_root / 'default').is_dir()
@@ -557,9 +565,10 @@ async def test_profile_default_provides_defaults():
assert result['ok'] is True assert result['ok'] is True
spec = backend.start_specs[0] spec = backend.start_specs[0]
profile = BUILTIN_PROFILES['default']
assert spec.network == BoxNetworkMode.OFF assert spec.network == BoxNetworkMode.OFF
assert spec.image == 'python:3.11-slim' assert spec.image == profile.image
assert spec.timeout_sec == 30 assert spec.timeout_sec == profile.timeout_sec
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -698,7 +707,7 @@ async def test_box_service_applies_workspace_quota_from_config(tmp_path):
host_dir = tmp_path / 'default-workspace' host_dir = tmp_path / 'default-workspace'
host_dir.mkdir() host_dir.mkdir()
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=32) app = make_app(logger, [str(tmp_path)], workspace_quota_mb=32)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir) app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
@@ -716,7 +725,7 @@ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quot
host_dir.mkdir() host_dir.mkdir()
(host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024)) (host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024))
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir) app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
@@ -735,7 +744,7 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
host_dir = tmp_path / 'quota-workspace-post' host_dir = tmp_path / 'quota-workspace-post'
host_dir.mkdir() host_dir.mkdir()
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir) app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize() await service.initialize()
+3
View File
@@ -79,6 +79,7 @@ async def test_workspace_session_execute_for_query_uses_session_payload():
'session_id': 'skill-person_123-demo', 'session_id': 'skill-person_123-demo',
'workdir': '/workspace', 'workdir': '/workspace',
'env': {'FOO': 'bar'}, 'env': {'FOO': 'bar'},
'persistent': False,
'host_path': '/tmp/project', 'host_path': '/tmp/project',
'host_path_mode': 'rw', 'host_path_mode': 'rw',
'cmd': 'python run.py', 'cmd': 'python run.py',
@@ -111,6 +112,7 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args
'args': ['/workspace/server.py', '--config', '/workspace/config.json'], 'args': ['/workspace/server.py', '--config', '/workspace/config.json'],
'env': {'TOKEN': '1'}, 'env': {'TOKEN': '1'},
'cwd': '/workspace', 'cwd': '/workspace',
'process_id': 'default',
} }
@@ -133,6 +135,7 @@ def test_workspace_session_build_session_payload_keeps_generic_workspace_shape()
'session_id': 'workspace-1', 'session_id': 'workspace-1',
'workdir': '/workspace', 'workdir': '/workspace',
'env': {'FOO': 'bar'}, 'env': {'FOO': 'bar'},
'persistent': False,
'network': 'on', 'network': 'on',
'read_only_rootfs': False, 'read_only_rootfs': False,
'host_path': '/tmp/project', 'host_path': '/tmp/project',
@@ -528,7 +528,7 @@ class TestGetRuntimeInfoDict:
ap=ap, ap=ap,
) )
info = s.get_runtime_info_dict() info = s.get_runtime_info_dict()
assert info['box_session_id'] == 'mcp-test-uuid' assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True assert info['box_enabled'] is True
def test_stdio_session_without_box_runtime(self, mcp_module): def test_stdio_session_without_box_runtime(self, mcp_module):
+13 -14
View File
@@ -92,12 +92,7 @@ class TestSkillManagerActivation:
'beta': _make_skill_data(name='beta'), 'beta': _make_skill_data(name='beta'),
} }
response = ( response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\n[ACTIVATE_SKILL: alpha]\nLet me handle this.'
'[ACTIVATE_SKILL: alpha]\n'
'[ACTIVATE_SKILL: beta]\n'
'[ACTIVATE_SKILL: alpha]\n'
'Let me handle this.'
)
assert mgr.detect_skill_activations(response) == ['alpha', 'beta'] assert mgr.detect_skill_activations(response) == ['alpha', 'beta']
assert mgr.detect_skill_activation(response) == 'alpha' assert mgr.detect_skill_activation(response) == 'alpha'
@@ -240,7 +235,9 @@ class TestSkillAuthoringToolLoader:
ap = _make_ap() ap = _make_ap()
ap.skill_service = SimpleNamespace( ap.skill_service = SimpleNamespace(
create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')), create_skill=AsyncMock(
return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')
),
reload_skills=AsyncMock(), reload_skills=AsyncMock(),
list_skills=AsyncMock(return_value=[]), list_skills=AsyncMock(return_value=[]),
) )
@@ -329,7 +326,9 @@ class TestSkillAuthoringToolLoader:
ap = _make_ap() ap = _make_ap()
ap.skill_service = SimpleNamespace( ap.skill_service = SimpleNamespace(
create_skill=AsyncMock(), create_skill=AsyncMock(),
update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')), update_skill=AsyncMock(
return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')
),
reload_skills=AsyncMock(), reload_skills=AsyncMock(),
list_skills=AsyncMock(return_value=[]), list_skills=AsyncMock(return_value=[]),
) )
@@ -393,7 +392,7 @@ class TestSkillAuthoringToolLoader:
) )
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace')
ap.skill_service = SimpleNamespace( ap.skill_service = SimpleNamespace(
scan_directory=Mock( scan_directory=Mock(
return_value={ return_value={
@@ -413,7 +412,7 @@ class TestSkillAuthoringToolLoader:
await loader.initialize() await loader.initialize()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
ap.box_service.default_host_workspace = tmpdir ap.box_service.default_workspace = tmpdir
repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill') repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill')
os.makedirs(repo_dir) os.makedirs(repo_dir)
@@ -445,7 +444,7 @@ class TestSkillAuthoringToolLoader:
) )
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace')
ap.skill_service = SimpleNamespace( ap.skill_service = SimpleNamespace(
scan_directory=Mock(), scan_directory=Mock(),
create_skill=AsyncMock(), create_skill=AsyncMock(),
@@ -501,7 +500,7 @@ class TestNativeToolLoaderSkillPaths:
f.write('demo instructions') f.write('demo instructions')
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap) loader = NativeToolLoader(ap)
@@ -522,7 +521,7 @@ class TestNativeToolLoaderSkillPaths:
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace( ap.box_service = SimpleNamespace(
available=True, available=True,
default_host_workspace=tmpdir, default_workspace=tmpdir,
execute_spec_payload=AsyncMock(return_value={'ok': True}), execute_spec_payload=AsyncMock(return_value={'ok': True}),
) )
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
@@ -555,7 +554,7 @@ class TestNativeToolLoaderSkillPaths:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap) loader = NativeToolLoader(ap)
@@ -110,7 +110,7 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]: def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
logger = Mock() logger = Mock()
box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap = SimpleNamespace(box_service=box_service, logger=logger) ap = SimpleNamespace(box_service=box_service, logger=logger)
return NativeToolLoader(ap), logger return NativeToolLoader(ap), logger
+127 -112
View File
@@ -16,6 +16,7 @@ import {
Download, Download,
PlusIcon, PlusIcon,
ChevronLeft, ChevronLeft,
ChevronRight,
Server, Server,
Github, Github,
BookOpen, BookOpen,
@@ -25,26 +26,20 @@ import {
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin'; import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm'; import type {
MCPFormDraft,
MCPFormHandle,
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import type { SkillFormDraft } from '@/app/home/skills/components/skill-form/SkillForm';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -100,7 +95,6 @@ export default function AddExtensionPage() {
function AddExtensionContent() { function AddExtensionContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const { const {
addTask, addTask,
@@ -128,11 +122,15 @@ function AddExtensionContent() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const mcpFormRef = useRef<MCPFormHandle>(null); const mcpFormRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false); const [mcpTesting, setMcpTesting] = useState(false);
const [mcpDraft, setMcpDraft] = useState<MCPFormDraft | undefined>();
const [skillDraft, setSkillDraft] = useState<SkillFormDraft | undefined>();
// GitHub install state // GitHub install state
const [githubURL, setGithubURL] = useState(''); const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]); const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(null); const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]); const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null); const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [githubOwner, setGithubOwner] = useState(''); const [githubOwner, setGithubOwner] = useState('');
@@ -141,12 +139,14 @@ function AddExtensionContent() {
const [fetchingAssets, setFetchingAssets] = useState(false); const [fetchingAssets, setFetchingAssets] = useState(false);
const [githubInstallStatus, setGithubInstallStatus] = const [githubInstallStatus, setGithubInstallStatus] =
useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT); useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT);
const [githubInstallError, setGithubInstallError] = useState<string | null>(null); const [githubInstallError, setGithubInstallError] = useState<string | null>(
null,
);
useEffect(() => { useEffect(() => {
// Clear any stale completed tasks on mount // Clear any stale completed tasks on mount
clearCompletedTasks(); clearCompletedTasks();
}, []); }, [clearCompletedTasks]);
useEffect(() => { useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => { const onComplete = (_taskId: number, success: boolean) => {
@@ -161,20 +161,17 @@ function AddExtensionContent() {
}; };
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]); }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback( const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
async (plugin: PluginV4) => { setInstallInfo({
setInstallInfo({ plugin_author: plugin.author,
plugin_author: plugin.author, plugin_name: plugin.name,
plugin_name: plugin.name, plugin_version: plugin.latest_version,
plugin_version: plugin.latest_version, });
}); setInstallExtensionType(plugin.type || 'plugin');
setInstallExtensionType(plugin.type || 'plugin'); setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); setInstallError(null);
setInstallError(null); setModalOpen(true);
setModalOpen(true); }, []);
},
[],
);
function handleModalConfirm() { function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING); setPluginInstallStatus(PluginInstallStatus.INSTALLING);
@@ -266,7 +263,7 @@ function AddExtensionContent() {
}); });
} }
}, },
[t, addTask, setSelectedTaskId, refreshPlugins], [t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills],
); );
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
@@ -308,13 +305,15 @@ function AddExtensionContent() {
[uploadFile], [uploadFile],
); );
function handleMCPCreated(serverName: string) { function handleMCPCreated(_serverName: string) {
setMcpDraft(undefined);
refreshMCPServers(); refreshMCPServers();
setPopoverView('menu'); setPopoverView('menu');
setPopoverOpen(false); setPopoverOpen(false);
} }
function handleSkillCreated(skillName: string) { function handleSkillCreated(_skillName: string) {
setSkillDraft(undefined);
refreshPlugins(); refreshPlugins();
refreshSkills(); refreshSkills();
setPopoverView('menu'); setPopoverView('menu');
@@ -467,13 +466,13 @@ function AddExtensionContent() {
function getPopoverWidth(): string { function getPopoverWidth(): string {
switch (popoverView) { switch (popoverView) {
case 'mcp': case 'mcp':
return 'w-[500px]'; return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'skill': case 'skill':
return 'w-[460px]'; return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'github': case 'github':
return 'w-[460px]'; return 'w-[calc(100vw-2rem)] sm:w-[480px]';
default: default:
return 'w-[360px]'; return 'w-[calc(100vw-2rem)] sm:w-[380px]';
} }
} }
@@ -491,9 +490,6 @@ function AddExtensionContent() {
open={popoverOpen} open={popoverOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setPopoverOpen(open); setPopoverOpen(open);
if (!open) {
setPopoverView('menu');
}
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -508,12 +504,13 @@ function AddExtensionContent() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className={`${getPopoverWidth()} p-4 max-h-[80vh] overflow-y-auto`} forceMount
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
align="end" align="end"
> >
{/* ===== Menu View ===== */} {/* ===== Menu View ===== */}
{popoverView === 'menu' && ( {popoverView === 'menu' && (
<div className="space-y-4"> <div className="space-y-4 p-4">
{/* File upload area */} {/* File upload area */}
<div <div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${ className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
@@ -539,68 +536,75 @@ function AddExtensionContent() {
</p> </p>
</div> </div>
{/* Divider */} <p className="text-center text-xs text-muted-foreground">
<div className="relative"> {t('addExtension.orContinueWith')}
<div className="absolute inset-0 flex items-center"> </p>
<span className="w-full border-t" />
</div> <div className="space-y-2">
<div className="relative flex justify-center text-xs"> <button
<span className="bg-popover px-2 text-muted-foreground"> type="button"
{t('addExtension.orContinueWith')} className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('mcp')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<Server className="size-4" />
</span> </span>
</div> <span className="min-w-0 flex-1 space-y-0.5">
</div> <span className="block text-sm font-medium leading-none">
{t('mcp.addMCPServer')}
</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{t('addExtension.addMCPServerHint')}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
{/* MCP Config button */} <button
<Button type="button"
variant="outline" className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="w-full justify-start gap-2"
onClick={() => setPopoverView('mcp')}
>
<Server className="w-4 h-4" />
{t('mcp.addMCPServer')}
</Button>
{/* Two side-by-side buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
className="flex-col h-auto py-3 gap-1"
onClick={() => setPopoverView('github')} onClick={() => setPopoverView('github')}
> >
<Github className="w-4 h-4" /> <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<span className="text-xs"> <Github className="size-4" />
{t('addExtension.installFromGithub')}
</span> </span>
</Button> <span className="min-w-0 flex-1 space-y-0.5">
<Button <span className="block text-sm font-medium leading-none">
variant="outline" {t('addExtension.installFromGithub')}
className="flex-col h-auto py-3 gap-1" </span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{t('addExtension.installFromGithubHint')}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('skill')} onClick={() => setPopoverView('skill')}
> >
<BookOpen className="w-4 h-4" /> <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<span className="text-xs"> <BookOpen className="size-4" />
{t('addExtension.createSkill')}
</span> </span>
</Button> <span className="min-w-0 flex-1 space-y-0.5">
</div> <span className="block text-sm font-medium leading-none">
{t('addExtension.createSkill')}
{/* Hints for the two buttons */} </span>
<div className="grid grid-cols-2 gap-2"> <span className="block text-xs leading-relaxed text-muted-foreground">
<p className="text-[11px] text-muted-foreground text-center px-1"> {t('addExtension.createSkillHint')}
{t('addExtension.installFromGithubHint')} </span>
</p> </span>
<p className="text-[11px] text-muted-foreground text-center px-1"> <ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
{t('addExtension.createSkillHint')} </button>
</p>
</div> </div>
</div> </div>
)} )}
{/* ===== MCP Form View ===== */} {/* ===== MCP Form View ===== */}
{popoverView === 'mcp' && ( {popoverView === 'mcp' && (
<div className="space-y-3"> <div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -614,17 +618,19 @@ function AddExtensionContent() {
</h4> </h4>
</div> </div>
<div className="max-h-[60vh] overflow-y-auto pr-1"> <div className="min-h-0 flex-1 overflow-y-auto p-4">
<MCPForm <MCPForm
ref={mcpFormRef} ref={mcpFormRef}
initServerName={undefined} initServerName={undefined}
initialDraft={mcpDraft}
onFormSubmit={() => {}} onFormSubmit={() => {}}
onNewServerCreated={handleMCPCreated} onNewServerCreated={handleMCPCreated}
onDraftChange={setMcpDraft}
onTestingChange={setMcpTesting} onTestingChange={setMcpTesting}
/> />
</div> </div>
<div className="flex items-center justify-end gap-2 pt-2 border-t"> <div className="flex items-center justify-end gap-2 bg-popover px-4 pb-4 pt-1">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -652,8 +658,8 @@ function AddExtensionContent() {
{/* ===== Skill Form View ===== */} {/* ===== Skill Form View ===== */}
{popoverView === 'skill' && ( {popoverView === 'skill' && (
<div className="space-y-3"> <div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -667,15 +673,17 @@ function AddExtensionContent() {
</h4> </h4>
</div> </div>
<div className="max-h-[60vh] overflow-y-auto pr-1"> <div className="min-h-0 flex-1 overflow-y-auto p-4">
<SkillForm <SkillForm
initSkillName={undefined} initSkillName={undefined}
initialDraft={skillDraft}
onNewSkillCreated={handleSkillCreated} onNewSkillCreated={handleSkillCreated}
onSkillUpdated={() => {}} onSkillUpdated={() => {}}
onDraftChange={setSkillDraft}
/> />
</div> </div>
<div className="flex items-center justify-end gap-2 pt-2 border-t"> <div className="flex items-center justify-end gap-2 bg-popover px-4 pb-4 pt-1">
<Button type="submit" form="skill-form" size="sm"> <Button type="submit" form="skill-form" size="sm">
{t('common.save')} {t('common.save')}
</Button> </Button>
@@ -685,8 +693,8 @@ function AddExtensionContent() {
{/* ===== GitHub Install View ===== */} {/* ===== GitHub Install View ===== */}
{popoverView === 'github' && ( {popoverView === 'github' && (
<div className="space-y-3"> <div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -703,7 +711,7 @@ function AddExtensionContent() {
</h4> </h4>
</div> </div>
<div className="max-h-[60vh] overflow-y-auto pr-1 space-y-3"> <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && ( {githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -743,7 +751,9 @@ function AddExtensionContent() {
size="sm" size="sm"
className="h-6 text-xs px-2" className="h-6 text-xs px-2"
onClick={() => { onClick={() => {
setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT); setGithubInstallStatus(
GithubInstallStatus.WAIT_INPUT,
);
setGithubReleases([]); setGithubReleases([]);
}} }}
> >
@@ -755,7 +765,7 @@ function AddExtensionContent() {
{githubReleases.map((release) => ( {githubReleases.map((release) => (
<div <div
key={release.id} key={release.id}
className="flex items-center justify-between rounded-md border p-2 hover:bg-accent cursor-pointer text-sm" className="flex cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm hover:bg-accent"
onClick={() => handleReleaseSelect(release)} onClick={() => handleReleaseSelect(release)}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -764,7 +774,9 @@ function AddExtensionContent() {
</div> </div>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{release.tag_name} &bull;{' '} {release.tag_name} &bull;{' '}
{new Date(release.published_at).toLocaleDateString()} {new Date(
release.published_at,
).toLocaleDateString()}
</div> </div>
</div> </div>
{release.prerelease && ( {release.prerelease && (
@@ -795,7 +807,9 @@ function AddExtensionContent() {
size="sm" size="sm"
className="h-6 text-xs px-2" className="h-6 text-xs px-2"
onClick={() => { onClick={() => {
setGithubInstallStatus(GithubInstallStatus.SELECT_RELEASE); setGithubInstallStatus(
GithubInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]); setGithubAssets([]);
setSelectedAsset(null); setSelectedAsset(null);
}} }}
@@ -805,7 +819,7 @@ function AddExtensionContent() {
</Button> </Button>
</div> </div>
{selectedRelease && ( {selectedRelease && (
<div className="p-1.5 bg-muted rounded text-[11px]"> <div className="rounded-md bg-muted/40 px-2 py-1.5 text-[11px]">
<span className="font-medium"> <span className="font-medium">
{selectedRelease.name || selectedRelease.tag_name} {selectedRelease.name || selectedRelease.tag_name}
</span> </span>
@@ -815,7 +829,7 @@ function AddExtensionContent() {
{githubAssets.map((asset) => ( {githubAssets.map((asset) => (
<div <div
key={asset.id} key={asset.id}
className="flex items-center justify-between rounded-md border p-2 hover:bg-accent cursor-pointer" className="flex cursor-pointer items-center justify-between rounded-md px-2 py-2 hover:bg-accent"
onClick={() => handleAssetSelect(asset)} onClick={() => handleAssetSelect(asset)}
> >
<span className="text-xs truncate">{asset.name}</span> <span className="text-xs truncate">{asset.name}</span>
@@ -839,7 +853,9 @@ function AddExtensionContent() {
size="sm" size="sm"
className="h-6 text-xs px-2" className="h-6 text-xs px-2"
onClick={() => { onClick={() => {
setGithubInstallStatus(GithubInstallStatus.SELECT_ASSET); setGithubInstallStatus(
GithubInstallStatus.SELECT_ASSET,
);
setSelectedAsset(null); setSelectedAsset(null);
}} }}
> >
@@ -848,10 +864,12 @@ function AddExtensionContent() {
</Button> </Button>
</div> </div>
{selectedRelease && selectedAsset && ( {selectedRelease && selectedAsset && (
<div className="p-2 bg-muted rounded space-y-1 text-xs"> <div className="space-y-1 rounded-md bg-muted/40 px-2 py-2 text-xs">
<div> <div>
<span className="font-medium">Repository: </span> <span className="font-medium">Repository: </span>
<span>{githubOwner}/{githubRepo}</span> <span>
{githubOwner}/{githubRepo}
</span>
</div> </div>
<div> <div>
<span className="font-medium">Release: </span> <span className="font-medium">Release: </span>
@@ -863,10 +881,7 @@ function AddExtensionContent() {
</div> </div>
</div> </div>
)} )}
<Button <Button className="w-full" onClick={handleGithubConfirm}>
className="w-full"
onClick={handleGithubConfirm}
>
{t('common.confirm')} {t('common.confirm')}
</Button> </Button>
</div> </div>
@@ -11,13 +11,6 @@ import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { import {
Form, Form,
FormControl, FormControl,
@@ -94,18 +87,16 @@ function StatusDisplay({
// Tools list component // Tools list component
function ToolsList({ tools }: { tools: MCPTool[] }) { function ToolsList({ tools }: { tools: MCPTool[] }) {
return ( return (
<div className="space-y-2 max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] space-y-1 overflow-y-auto">
{tools.map((tool, index) => ( {tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none"> <div key={index} className="rounded-md px-1 py-2">
<CardHeader> <div className="text-sm font-medium">{tool.name}</div>
<CardTitle className="text-sm">{tool.name}</CardTitle> {tool.description && (
{tool.description && ( <div className="mt-0.5 text-xs text-muted-foreground">
<CardDescription className="text-xs"> {tool.description}
{tool.description} </div>
</CardDescription> )}
)} </div>
</CardHeader>
</Card>
))} ))}
</div> </div>
); );
@@ -164,10 +155,14 @@ type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
ssereadtimeout: number; ssereadtimeout: number;
}; };
export type MCPFormDraft = Partial<FormValues>;
interface MCPFormProps { interface MCPFormProps {
initServerName?: string; initServerName?: string;
initialDraft?: MCPFormDraft;
onFormSubmit: () => void; onFormSubmit: () => void;
onNewServerCreated: (serverName: string) => void; onNewServerCreated: (serverName: string) => void;
onDraftChange?: (draft: MCPFormDraft) => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void; onTestingChange?: (testing: boolean) => void;
} }
@@ -181,8 +176,10 @@ export interface MCPFormHandle {
const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm( const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
{ {
initServerName, initServerName,
initialDraft,
onFormSubmit, onFormSubmit,
onNewServerCreated, onNewServerCreated,
onDraftChange,
onDirtyChange, onDirtyChange,
onTestingChange, onTestingChange,
}, },
@@ -191,6 +188,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
const isEditMode = !!initServerName; const isEditMode = !!initServerName;
const initialDraftRef = useRef(initialDraft);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>, resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
@@ -203,6 +201,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
timeout: 30, timeout: 30,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
...initialDraftRef.current,
}, },
}); });
@@ -259,9 +258,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
timeout: 30, timeout: 30,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
...initialDraftRef.current,
}); });
setExtraArgs([]); setExtraArgs(initialDraftRef.current?.extra_args ?? []);
setStdioArgs([]); setStdioArgs(initialDraftRef.current?.args ?? []);
setRuntimeInfo(null); setRuntimeInfo(null);
isInitializing.current = false; isInitializing.current = false;
} }
@@ -274,6 +274,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}; };
}, [initServerName]); }, [initServerName]);
useEffect(() => {
if (!onDraftChange || isEditMode) return;
const subscription = form.watch((values) => {
onDraftChange({
...values,
extra_args: extraArgs,
args: stdioArgs,
} as MCPFormDraft);
});
return () => subscription.unsubscribe();
}, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
// Poll for updates when runtime_info status is CONNECTING // Poll for updates when runtime_info status is CONNECTING
useEffect(() => { useEffect(() => {
if ( if (
@@ -595,126 +609,136 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<form <form
id="mcp-form" id="mcp-form"
onSubmit={form.handleSubmit(handleFormSubmit)} onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-6" className="space-y-5"
> >
{/* Runtime info: status + tools (edit mode only) */} {/* Runtime info: status + tools (edit mode only) */}
{isEditMode && runtimeInfo && ( {isEditMode && runtimeInfo && (
<Card> <section className="space-y-3">
<CardHeader> <h3 className="text-sm font-medium">{t('mcp.title')}</h3>
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle> {(mcpTesting ||
</CardHeader> runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<CardContent className="space-y-3"> <div className="rounded-md bg-muted/40 p-3">
{(mcpTesting || <StatusDisplay
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( testing={mcpTesting}
<div className="p-3 rounded-lg border"> runtimeInfo={runtimeInfo}
<StatusDisplay t={t}
testing={mcpTesting} />
runtimeInfo={runtimeInfo} </div>
t={t} )}
/>
</div>
)}
{!mcpTesting && {!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED && runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && ( runtimeInfo.tools?.length > 0 && (
<> <>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{t('mcp.toolCount', { {t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0, count: runtimeInfo.tools?.length || 0,
})} })}
</div> </div>
<ToolsList tools={runtimeInfo.tools} /> <ToolsList tools={runtimeInfo.tools} />
</> </>
)} )}
</CardContent> </section>
</Card>
)} )}
{/* Server configuration */} {/* Server configuration */}
<Card> <section className="space-y-4">
<CardHeader> {isEditMode && (
<CardTitle> <h3 className="text-sm font-medium">{t('mcp.editServer')}</h3>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')} )}
</CardTitle> <FormField
<CardDescription> control={form.control}
{t('mcp.extraParametersDescription')} name="name"
</CardDescription> render={({ field }) => (
</CardHeader> <FormItem>
<CardContent className="space-y-4"> <FormLabel>
<FormField {t('mcp.name')}
control={form.control} <span className="text-destructive">*</span>
name="name" </FormLabel>
render={({ field }) => ( <FormControl>
<FormItem> <Input {...field} disabled={isEditMode} />
<FormLabel> </FormControl>
{t('mcp.name')} <FormMessage />
<span className="text-destructive">*</span> </FormItem>
</FormLabel> )}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl> <FormControl>
<Input {...field} disabled={isEditMode} /> <SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl> </FormControl>
<FormMessage /> <SelectContent>
</FormItem> <SelectItem value="http">{t('mcp.http')}</SelectItem>
)} <SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
/> <SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField {(watchMode === 'sse' || watchMode === 'http') && (
control={form.control} <>
name="mode" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="url"
<FormLabel>{t('mcp.serverMode')}</FormLabel> render={({ field }) => (
<Select <FormItem>
onValueChange={field.onChange} <FormLabel>
defaultValue={field.value} {t('mcp.url')}
value={field.value} <span className="text-destructive">*</span>
> </FormLabel>
<FormControl> <FormControl>
<SelectTrigger> <Input {...field} />
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <FormMessage />
<SelectItem value="http">{t('mcp.http')}</SelectItem> </FormItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem> )}
<SelectItem value="sse">{t('mcp.sse')}</SelectItem> />
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchMode === 'sse' || watchMode === 'http') && ( <FormField
<> control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField <FormField
control={form.control} control={form.control}
name="url" name="ssereadtimeout"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t('mcp.sseTimeout')}</FormLabel>
{t('mcp.url')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
placeholder={t('mcp.timeout')} placeholder={t('mcp.sseTimeoutDescription')}
{...field} {...field}
onChange={(e) => onChange={(e) =>
field.onChange(Number(e.target.value)) field.onChange(Number(e.target.value))
@@ -725,133 +749,104 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</FormItem> </FormItem>
)} )}
/> />
)}
</>
)}
{watchMode === 'sse' && ( {watchMode === 'stdio' && (
<FormField <>
control={form.control} <FormField
name="ssereadtimeout" control={form.control}
render={({ field }) => ( name="command"
<FormItem> render={({ field }) => (
<FormLabel>{t('mcp.sseTimeout')}</FormLabel> <FormItem>
<FormControl> <FormLabel>
<Input {t('mcp.command')}
type="number" <span className="text-destructive">*</span>
placeholder={t('mcp.sseTimeoutDescription')} </FormLabel>
{...field} <FormControl>
onChange={(e) => <Input {...field} />
field.onChange(Number(e.target.value)) </FormControl>
} <FormMessage />
/> </FormItem>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)} )}
</> />
)}
{watchMode === 'stdio' && ( <FormItem>
<> <FormLabel>{t('mcp.args')}</FormLabel>
<FormField <div className="space-y-2">
control={form.control} {stdioArgs.map((arg, index) => (
name="command" <div key={index} className="flex gap-2">
render={({ field }) => ( <Input
<FormItem> placeholder={t('mcp.args')}
<FormLabel> value={arg.value}
{t('mcp.command')} onChange={(e) => updateStdioArg(index, e.target.value)}
<span className="text-destructive">*</span> />
</FormLabel> <Button
<FormControl> type="button"
<Input {...field} /> variant="ghost"
</FormControl> size="icon"
<FormMessage /> className="shrink-0 text-red-500 hover:text-red-600"
</FormItem> onClick={() => removeStdioArg(index)}
)} >
/> <Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addStdioArg}>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem> <FormItem>
<FormLabel>{t('mcp.args')}</FormLabel> <FormLabel>
<div className="space-y-2"> {watchMode === 'sse' || watchMode === 'http'
{stdioArgs.map((arg, index) => ( ? t('mcp.headers')
<div key={index} className="flex gap-2"> : t('mcp.env')}
<Input </FormLabel>
placeholder={t('mcp.args')} <div className="space-y-2">
value={arg.value} {extraArgs.map((arg, index) => (
onChange={(e) => <div key={index} className="flex gap-2">
updateStdioArg(index, e.target.value) <Input
} placeholder={t('models.keyName')}
/> value={arg.key}
<Button onChange={(e) =>
type="button" updateExtraArg(index, 'key', e.target.value)
variant="ghost" }
size="icon" />
className="shrink-0 text-red-500 hover:text-red-600" <Input
onClick={() => removeStdioArg(index)} placeholder={t('models.value')}
> value={arg.value}
<Trash2 className="w-5 h-5" /> onChange={(e) =>
</Button> updateExtraArg(index, 'value', e.target.value)
</div> }
))} />
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={addStdioArg} size="icon"
> className="shrink-0 text-red-500 hover:text-red-600"
{t('mcp.addArgument')} onClick={() => removeExtraArg(index)}
</Button> >
</div> <Trash2 className="w-5 h-5" />
</FormItem> </Button>
</> </div>
)} ))}
<Button type="button" variant="outline" onClick={addExtraArg}>
<FormItem>
<FormLabel>
{watchMode === 'sse' || watchMode === 'http' {watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers') ? t('mcp.addHeader')
: t('mcp.env')} : t('mcp.addEnvVar')}
</FormLabel> </Button>
<div className="space-y-2"> </div>
{extraArgs.map((arg, index) => ( <FormDescription>
<div key={index} className="flex gap-2"> {t('mcp.extraParametersDescription')}
<Input </FormDescription>
placeholder={t('models.keyName')} <FormMessage />
value={arg.key} </FormItem>
onChange={(e) => </section>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeExtraArg(index)}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</CardContent>
</Card>
</form> </form>
</Form> </Form>
); );
@@ -143,7 +143,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
</div> </div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent> <DialogContent className="max-h-[min(420px,80vh)] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> <DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -12,52 +12,72 @@ import { toast } from 'sonner';
interface SkillFormProps { interface SkillFormProps {
initSkillName?: string; initSkillName?: string;
initialDraft?: SkillFormDraft;
onNewSkillCreated: (skillName: string) => void; onNewSkillCreated: (skillName: string) => void;
onSkillUpdated: (skillName: string) => void; onSkillUpdated: (skillName: string) => void;
onDraftChange?: (draft: SkillFormDraft) => void;
} }
export default function SkillForm({ export interface SkillFormDraft {
initSkillName, skill: Partial<Skill>;
onNewSkillCreated, showAdvanced: boolean;
onSkillUpdated, }
}: SkillFormProps) {
const { t } = useTranslation(); const emptySkillDraft: SkillFormDraft = {
const [skill, setSkill] = useState<Partial<Skill>>({ skill: {
name: '', name: '',
display_name: '', display_name: '',
description: '', description: '',
instructions: '', instructions: '',
package_root: '', package_root: '',
auto_activate: true, auto_activate: true,
}); },
showAdvanced: false,
};
export default function SkillForm({
initSkillName,
initialDraft,
onNewSkillCreated,
onSkillUpdated,
onDraftChange,
}: SkillFormProps) {
const { t } = useTranslation();
const initialDraftRef = useRef(initialDraft ?? emptySkillDraft);
const [skill, setSkill] = useState<Partial<Skill>>(
initialDraftRef.current.skill,
);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(
initialDraftRef.current.showAdvanced,
);
const loadSkill = useCallback(
async (skillName: string) => {
try {
const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill);
} catch (error) {
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
}
},
[t],
);
useEffect(() => { useEffect(() => {
if (initSkillName) { if (initSkillName) {
loadSkill(initSkillName); loadSkill(initSkillName);
return; return;
} }
setSkill({ setSkill(initialDraftRef.current.skill);
name: '', setShowAdvanced(initialDraftRef.current.showAdvanced);
display_name: '', }, [initSkillName, loadSkill]);
description: '',
instructions: '',
package_root: '',
auto_activate: true,
});
setShowAdvanced(false);
}, [initSkillName]);
async function loadSkill(skillName: string) { useEffect(() => {
try { if (initSkillName) return;
const resp = await httpClient.getSkill(skillName); onDraftChange?.({ skill, showAdvanced });
setSkill(resp.skill); }, [initSkillName, onDraftChange, skill, showAdvanced]);
} catch (error) {
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
}
}
async function scanDirectory() { async function scanDirectory() {
const path = skill.package_root?.trim(); const path = skill.package_root?.trim();
@@ -183,10 +203,16 @@ export default function SkillForm({
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-4">
<Label htmlFor="auto_activate">{t('skills.autoActivate')}</Label> <div className="space-y-1">
<Label htmlFor="auto_activate">{t('skills.autoActivate')}</Label>
<p className="text-xs leading-relaxed text-muted-foreground">
{t('skills.autoActivateDescription')}
</p>
</div>
<Switch <Switch
id="auto_activate" id="auto_activate"
className="mt-0.5"
checked={skill.auto_activate ?? true} checked={skill.auto_activate ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSkill({ ...skill, auto_activate: checked }) setSkill({ ...skill, auto_activate: checked })
@@ -194,10 +220,10 @@ export default function SkillForm({
/> />
</div> </div>
<div className="border rounded-md"> <div className="space-y-3">
<button <button
type="button" type="button"
className="flex items-center justify-between w-full p-3 text-sm font-medium text-left" className="flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left text-sm font-medium hover:bg-muted/70"
onClick={() => setShowAdvanced(!showAdvanced)} onClick={() => setShowAdvanced(!showAdvanced)}
> >
{t('skills.advancedSettings')} {t('skills.advancedSettings')}
@@ -208,7 +234,7 @@ export default function SkillForm({
)} )}
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="p-3 pt-0 space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('skills.packageRoot')}</Label> <Label>{t('skills.packageRoot')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
+3
View File
@@ -1347,6 +1347,8 @@ const enUS = {
skillDescription: 'Skill Description', skillDescription: 'Skill Description',
skillInstructions: 'Instructions', skillInstructions: 'Instructions',
autoActivate: 'Auto Activate', autoActivate: 'Auto Activate',
autoActivateDescription:
'When enabled, the Agent may match and activate this skill based on its description during conversations.',
saveSuccess: 'Saved successfully', saveSuccess: 'Saved successfully',
saveError: 'Save failed: ', saveError: 'Save failed: ',
createSuccess: 'Created successfully', createSuccess: 'Created successfully',
@@ -1475,6 +1477,7 @@ const enUS = {
uploadExtension: 'Drag & drop or click to upload', uploadExtension: 'Drag & drop or click to upload',
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files', uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
orContinueWith: 'or choose an action below', orContinueWith: 'or choose an action below',
addMCPServerHint: 'Connect an MCP tool server extension',
installFromGithub: 'Install Plugin from GitHub', installFromGithub: 'Install Plugin from GitHub',
installFromGithubHint: 'Install plugin extension from GitHub Release', installFromGithubHint: 'Install plugin extension from GitHub Release',
createSkill: 'Create New Skill', createSkill: 'Create New Skill',
+1
View File
@@ -1392,6 +1392,7 @@ const jaJP = {
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード', uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応', uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応',
orContinueWith: 'または以下の操作を選択', orContinueWith: 'または以下の操作を選択',
addMCPServerHint: 'MCPツールサーバー拡張を接続',
installFromGithub: 'GitHubからプラグインをインストール', installFromGithub: 'GitHubからプラグインをインストール',
installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール', installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール',
createSkill: '新しいスキルを作成', createSkill: '新しいスキルを作成',
+3
View File
@@ -1291,6 +1291,8 @@ const zhHans = {
skillDescription: '技能描述', skillDescription: '技能描述',
skillInstructions: '指令内容', skillInstructions: '指令内容',
autoActivate: '自动激活', autoActivate: '自动激活',
autoActivateDescription:
'开启后,Agent 会在对话中根据技能描述自动匹配并激活此技能。',
saveSuccess: '保存成功', saveSuccess: '保存成功',
saveError: '保存失败:', saveError: '保存失败:',
createSuccess: '创建成功', createSuccess: '创建成功',
@@ -1414,6 +1416,7 @@ const zhHans = {
uploadExtension: '拖拽或点击上传扩展包', uploadExtension: '拖拽或点击上传扩展包',
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件', uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
orContinueWith: '或选择以下操作', orContinueWith: '或选择以下操作',
addMCPServerHint: '连接一个 MCP 工具服务器扩展',
installFromGithub: '从 GitHub 安装插件', installFromGithub: '从 GitHub 安装插件',
installFromGithubHint: '从 GitHub Release 安装插件扩展', installFromGithubHint: '从 GitHub Release 安装插件扩展',
createSkill: '创建新的技能', createSkill: '创建新的技能',
+3
View File
@@ -1327,6 +1327,7 @@ const zhHant = {
uploadExtension: '拖拽或點擊上傳擴充套件', uploadExtension: '拖拽或點擊上傳擴充套件',
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案', uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
orContinueWith: '或選擇以下操作', orContinueWith: '或選擇以下操作',
addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
installFromGithub: '從 GitHub 安裝插件', installFromGithub: '從 GitHub 安裝插件',
installFromGithubHint: '從 GitHub Release 安裝插件擴充', installFromGithubHint: '從 GitHub Release 安裝插件擴充',
createSkill: '建立新的技能', createSkill: '建立新的技能',
@@ -1360,6 +1361,8 @@ const zhHant = {
skillDescription: '技能描述', skillDescription: '技能描述',
skillInstructions: '指令內容', skillInstructions: '指令內容',
autoActivate: '自動啟用', autoActivate: '自動啟用',
autoActivateDescription:
'開啟後,Agent 會在對話中根據技能描述自動匹配並啟用此技能。',
saveSuccess: '儲存成功', saveSuccess: '儲存成功',
saveError: '儲存失敗:', saveError: '儲存失敗:',
createSuccess: '創建成功', createSuccess: '創建成功',