mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
Merge remote-tracking branch 'origin/master' into deploy/prod-workspace-debug
This commit is contained in:
@@ -14,8 +14,8 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# Shared with the langbot service and sent only as a WebSocket handshake
|
||||
# header. Generate with: openssl rand -hex 32
|
||||
# Optional. Leave unset on both OSS services, or set the same value on
|
||||
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||
# Process-wide admission for every asyncio.to_thread() call.
|
||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||
@@ -77,8 +77,7 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# Must match langbot_plugin_runtime. Empty/missing values make the
|
||||
# external control channel fail closed.
|
||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||
# Must match the value supplied to langbot_box. The token is sent only
|
||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@101e453e916b39465a6294d6471c9eaae8725d5c",
|
||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@22e470d853e00116ad4fa63d3171f88646b06153",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -32,12 +32,13 @@ The `all` / `box` profile starts three services:
|
||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||
never put it in `box.runtime.endpoint` or commit it to config.
|
||||
|
||||
Every Compose deployment also needs one
|
||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
||||
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
||||
before `docker compose up`; the external Plugin Runtime fails closed when the
|
||||
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
||||
Secret shown in `docker/kubernetes.yaml`.
|
||||
A Compose deployment may optionally set
|
||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
||||
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
||||
defaults to leaving it unset on both sides. If enabled, generate one value with
|
||||
`openssl rand -hex 32`; configuring only one side causes the control connection
|
||||
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
||||
`docker/kubernetes.yaml`.
|
||||
|
||||
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
||||
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
|
||||
@@ -392,8 +392,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get plugin debug information including debug URL and key"""
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
|
||||
|
||||
# Get debug URL from config
|
||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||
@@ -403,6 +403,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
data={
|
||||
'debug_url': debug_url,
|
||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||
'expires_at': debug_info.get('expires_at', ''),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -251,6 +251,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
||||
if not self._control_token and allow_generate:
|
||||
self._control_token = secrets.token_urlsafe(48)
|
||||
if not self._control_token:
|
||||
return {}
|
||||
try:
|
||||
self._control_token = validate_runtime_secret(
|
||||
self._control_token,
|
||||
@@ -1968,11 +1970,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
with runtime_handler.installation_scope(binding):
|
||||
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
return {}
|
||||
return await self._runtime_handler().get_debug_info()
|
||||
return await self._runtime_handler().get_debug_info(execution_context)
|
||||
|
||||
async def emit_event(
|
||||
self,
|
||||
|
||||
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
with self.installation_scope(None):
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||
{},
|
||||
timeout=10,
|
||||
)
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||
{},
|
||||
timeout=10,
|
||||
action_context=execution_context,
|
||||
)
|
||||
return result
|
||||
|
||||
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
||||
|
||||
@@ -106,7 +106,12 @@ async def plugin_security_api(plugin_module):
|
||||
application.plugin_connector.require_workspace_context = AsyncMock()
|
||||
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
||||
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
||||
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
||||
application.plugin_connector.get_debug_info = AsyncMock(
|
||||
return_value={
|
||||
'plugin_debug_key': 'runtime-debug-secret',
|
||||
'expires_at': '2026-08-04T12:00:00Z',
|
||||
}
|
||||
)
|
||||
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
||||
application.plugin_connector.set_plugin_config = AsyncMock()
|
||||
|
||||
@@ -232,8 +237,9 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
||||
assert (await allowed.get_json())['data'] == {
|
||||
'debug_url': 'http://localhost:5401',
|
||||
'plugin_debug_key': 'runtime-debug-secret',
|
||||
'expires_at': '2026-08-04T12:00:00Z',
|
||||
}
|
||||
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -612,8 +612,13 @@ class TestDisabledPluginEarlyReturns:
|
||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||
|
||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||
execution_context = connector_module.ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
result = await connector.get_debug_info()
|
||||
result = await connector.get_debug_info(execution_context)
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
@@ -282,12 +282,11 @@ def test_closed_deployment_selects_instance_scoped_shared_profile():
|
||||
assert connector.runtime_profile == 'shared'
|
||||
|
||||
|
||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||
def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeypatch):
|
||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = make_connector()
|
||||
|
||||
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
||||
connector._control_headers(allow_generate=False)
|
||||
assert connector._control_headers(allow_generate=False) == {}
|
||||
|
||||
|
||||
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||
|
||||
@@ -57,6 +57,7 @@ function PluginListView() {
|
||||
const [debugInfo, setDebugInfo] = useState<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
expires_at: string;
|
||||
} | null>(null);
|
||||
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||
@@ -275,6 +276,13 @@ function PluginListView() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{debugInfo?.expires_at && (
|
||||
<p className="text-xs text-muted-foreground pl-[58px]">
|
||||
{t('plugins.debugKeyExpires', {
|
||||
time: new Date(debugInfo.expires_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{!debugInfo?.plugin_debug_key && (
|
||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||
{t('plugins.debugKeyDisabled')}
|
||||
|
||||
@@ -1091,6 +1091,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public getPluginDebugInfo(): Promise<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
expires_at: string;
|
||||
}> {
|
||||
return this.get('/api/v1/plugins/debug-info');
|
||||
}
|
||||
|
||||
@@ -521,9 +521,9 @@ const enUS = {
|
||||
debugInfoTitle: 'Plugin Debug Information',
|
||||
debugUrl: 'Debug URL',
|
||||
debugKey: 'Debug Key',
|
||||
debugKeyExpires: 'Rotates at {{time}}; each Workspace has a different key',
|
||||
noDebugKey: '(Not Set)',
|
||||
debugKeyDisabled:
|
||||
'Debug key is not set, plugin debugging does not require authentication',
|
||||
debugKeyDisabled: 'Debug credential is temporarily unavailable',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Status',
|
||||
boxConnected: 'Connected',
|
||||
|
||||
@@ -535,9 +535,11 @@ const esES = {
|
||||
debugInfoTitle: 'Información de depuración del plugin',
|
||||
debugUrl: 'URL de depuración',
|
||||
debugKey: 'Clave de depuración',
|
||||
debugKeyExpires:
|
||||
'Rota a las {{time}}; cada Workspace tiene una clave distinta',
|
||||
noDebugKey: '(No establecida)',
|
||||
debugKeyDisabled:
|
||||
'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación',
|
||||
'La credencial de depuración no está disponible temporalmente',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Estado',
|
||||
boxConnected: 'Conectado',
|
||||
|
||||
@@ -527,9 +527,10 @@ const jaJP = {
|
||||
debugInfoTitle: 'プラグインデバッグ情報',
|
||||
debugUrl: 'デバッグURL',
|
||||
debugKey: 'デバッグキー',
|
||||
debugKeyExpires:
|
||||
'{{time}} にローテーションします。Workspace ごとにキーが異なります',
|
||||
noDebugKey: '(未設定)',
|
||||
debugKeyDisabled:
|
||||
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
|
||||
debugKeyDisabled: 'デバッグ認証情報を一時的に利用できません',
|
||||
boxStatusTitle: 'Box ランタイム',
|
||||
boxStatus: 'ステータス',
|
||||
boxConnected: '接続済み',
|
||||
|
||||
@@ -534,9 +534,9 @@ const ruRU = {
|
||||
debugInfoTitle: 'Отладочная информация плагина',
|
||||
debugUrl: 'URL для отладки',
|
||||
debugKey: 'Ключ отладки',
|
||||
debugKeyExpires: 'Смена в {{time}}; у каждого Workspace свой ключ',
|
||||
noDebugKey: '(Не задан)',
|
||||
debugKeyDisabled:
|
||||
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
|
||||
debugKeyDisabled: 'Учетные данные отладки временно недоступны',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Статус',
|
||||
boxConnected: 'Подключено',
|
||||
|
||||
@@ -518,9 +518,9 @@ const thTH = {
|
||||
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
|
||||
debugUrl: 'URL ดีบัก',
|
||||
debugKey: 'คีย์ดีบัก',
|
||||
debugKeyExpires: 'หมุนเวียนเวลา {{time}}; แต่ละ Workspace ใช้คีย์ต่างกัน',
|
||||
noDebugKey: '(ไม่ได้ตั้งค่า)',
|
||||
debugKeyDisabled:
|
||||
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
|
||||
debugKeyDisabled: 'ข้อมูลรับรองการดีบักไม่พร้อมใช้งานชั่วคราว',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'สถานะ',
|
||||
boxConnected: 'เชื่อมต่อแล้ว',
|
||||
|
||||
@@ -529,9 +529,9 @@ const viVN = {
|
||||
debugInfoTitle: 'Thông tin gỡ lỗi Plugin',
|
||||
debugUrl: 'URL gỡ lỗi',
|
||||
debugKey: 'Khóa gỡ lỗi',
|
||||
debugKeyExpires: 'Xoay vòng lúc {{time}}; mỗi Workspace có khóa riêng',
|
||||
noDebugKey: '(Chưa đặt)',
|
||||
debugKeyDisabled:
|
||||
'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực',
|
||||
debugKeyDisabled: 'Thông tin xác thực gỡ lỗi tạm thời không khả dụng',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Trạng thái',
|
||||
boxConnected: 'Đã kết nối',
|
||||
|
||||
@@ -496,8 +496,9 @@ const zhHans = {
|
||||
debugInfoTitle: '插件调试信息',
|
||||
debugUrl: '调试地址',
|
||||
debugKey: '调试密钥',
|
||||
debugKeyExpires: '将于 {{time}} 轮换;每个工作区的密钥不同',
|
||||
noDebugKey: '(未设置)',
|
||||
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
|
||||
debugKeyDisabled: '调试凭据暂不可用',
|
||||
boxStatusTitle: 'Box 运行时',
|
||||
boxStatus: '状态',
|
||||
boxConnected: '已连接',
|
||||
|
||||
@@ -501,8 +501,9 @@ const zhHant = {
|
||||
debugInfoTitle: '外掛偵錯資訊',
|
||||
debugUrl: '偵錯位址',
|
||||
debugKey: '偵錯金鑰',
|
||||
debugKeyExpires: '將於 {{time}} 輪換;每個工作區的密鑰不同',
|
||||
noDebugKey: '(未設定)',
|
||||
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
|
||||
debugKeyDisabled: '偵錯憑據暫時無法使用',
|
||||
boxStatusTitle: 'Box 執行時',
|
||||
boxStatus: '狀態',
|
||||
boxConnected: '已連線',
|
||||
|
||||
Reference in New Issue
Block a user