Merge remote-tracking branch 'origin/master' into deploy/prod-workspace-debug

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