feat: expose Workspace-scoped rotating plugin debug keys (#2395)

* feat: add Workspace-scoped rotating plugin debug tokens

* chore: pin formatted Workspace debug runtime

* chore: pin merged Workspace debug runtime

* chore: pin tenant-safe debug runtime

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-08-04 19:20:06 +08:00
committed by GitHub
parent c08bfc8ced
commit d78546967c
20 changed files with 3713 additions and 3659 deletions
+3 -4
View File
@@ -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
View File
@@ -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",
+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`;
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', ''),
}
)
+4 -2
View File
@@ -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,
+7 -7
View File
@@ -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):
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<{
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')}
+1
View File
@@ -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');
}
+2 -2
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -2
View File
@@ -527,9 +527,10 @@ const jaJP = {
debugInfoTitle: 'プラグインデバッグ情報',
debugUrl: 'デバッグURL',
debugKey: 'デバッグキー',
debugKeyExpires:
'{{time}} にローテーションします。Workspace ごとにキーが異なります',
noDebugKey: '(未設定)',
debugKeyDisabled:
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
debugKeyDisabled: 'デバッグ認証情報を一時的に利用できません',
boxStatusTitle: 'Box ランタイム',
boxStatus: 'ステータス',
boxConnected: '接続済み',
+2 -2
View File
@@ -534,9 +534,9 @@ const ruRU = {
debugInfoTitle: 'Отладочная информация плагина',
debugUrl: 'URL для отладки',
debugKey: 'Ключ отладки',
debugKeyExpires: 'Смена в {{time}}; у каждого Workspace свой ключ',
noDebugKey: '(Не задан)',
debugKeyDisabled:
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
debugKeyDisabled: 'Учетные данные отладки временно недоступны',
boxStatusTitle: 'Box Runtime',
boxStatus: 'Статус',
boxConnected: 'Подключено',
+2 -2
View File
@@ -518,9 +518,9 @@ const thTH = {
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
debugUrl: 'URL ดีบัก',
debugKey: 'คีย์ดีบัก',
debugKeyExpires: 'หมุนเวียนเวลา {{time}}; แต่ละ Workspace ใช้คีย์ต่างกัน',
noDebugKey: '(ไม่ได้ตั้งค่า)',
debugKeyDisabled:
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
debugKeyDisabled: 'ข้อมูลรับรองการดีบักไม่พร้อมใช้งานชั่วคราว',
boxStatusTitle: 'Box Runtime',
boxStatus: 'สถานะ',
boxConnected: 'เชื่อมต่อแล้ว',
+2 -2
View File
@@ -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',
+2 -1
View File
@@ -496,8 +496,9 @@ const zhHans = {
debugInfoTitle: '插件调试信息',
debugUrl: '调试地址',
debugKey: '调试密钥',
debugKeyExpires: '将于 {{time}} 轮换;每个工作区的密钥不同',
noDebugKey: '(未设置)',
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
debugKeyDisabled: '调试凭据暂不可用',
boxStatusTitle: 'Box 运行时',
boxStatus: '状态',
boxConnected: '已连接',
+2 -1
View File
@@ -501,8 +501,9 @@ const zhHant = {
debugInfoTitle: '外掛偵錯資訊',
debugUrl: '偵錯位址',
debugKey: '偵錯金鑰',
debugKeyExpires: '將於 {{time}} 輪換;每個工作區的密鑰不同',
noDebugKey: '(未設定)',
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
debugKeyDisabled: '偵錯憑據暫時無法使用',
boxStatusTitle: 'Box 執行時',
boxStatus: '狀態',
boxConnected: '已連線',