From 7aab0cee07518f01c90a64b6afc33c7f8f41d0c7 Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 00:18:53 +0800 Subject: [PATCH 01/56] chore(release): bump version to 4.10.9 (#2488) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4692b7b94..fddfe5245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.8" +version = "4.10.9" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -70,7 +70,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.5.5", + "langbot-plugin==0.5.6", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 743cdf599..4a8e9e701 100644 --- a/uv.lock +++ b/uv.lock @@ -2008,7 +2008,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.8" +version = "4.10.9" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -2129,7 +2129,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.5" }, + { name = "langbot-plugin", specifier = "==0.5.6" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2196,7 +2196,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.5" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2217,9 +2217,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" }, ] [[package]] From bf8d418ad408b9ab2930da885f082ae8f574b0c4 Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 15:14:29 +0800 Subject: [PATCH 02/56] feat(monitoring): paginate sessions and messages (#2489) * feat(monitoring): paginate sessions and messages * fix(monitoring): align detail pages with local dates --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../api/http/controller/groups/monitoring.py | 11 +- .../pkg/api/http/service/monitoring.py | 24 +- tests/integration/api/test_monitoring.py | 26 +- .../api/service/test_monitoring_tenancy.py | 33 +++ .../bot-session/BotSessionMonitor.tsx | 223 +++++++++++++++++- web/src/app/infra/http/BackendClient.ts | 22 +- .../unit/session-monitor-pagination.test.mjs | 139 +++++++++++ 7 files changed, 455 insertions(+), 23 deletions(-) create mode 100644 web/tests/unit/session-monitor-pagination.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index d3aa03c2e..9a468ab3f 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup): pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') + user_query = quart.request.args.get('userQuery') is_active_str = quart.request.args.get('isActive') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) @@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup): pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, + user_query=user_query, is_active=is_active, limit=limit, offset=offset, @@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup): @self.route('/sessions//analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_session_analysis(session_id: str, request_context: RequestContext) -> str: """Get detailed analysis for a specific session""" - analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id) + start_time = parse_iso_datetime(quart.request.args.get('startTime')) + end_time = parse_iso_datetime(quart.request.args.get('endTime')) + analysis = await self.ap.monitoring_service.get_session_analysis( + request_context, + session_id, + start_time=start_time, + end_time=end_time, + ) # Always return success with the analysis data # The frontend will handle the 'found: false' case diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index b0363cda4..474a5c1d5 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -1257,6 +1257,7 @@ class MonitoringService: pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + user_query: str | None = None, is_active: bool | None = None, limit: int = 100, offset: int = 0, @@ -1274,6 +1275,14 @@ class MonitoringService: conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + if user_query and user_query.strip(): + user_pattern = f'%{user_query.strip()}%' + conditions.append( + sqlalchemy.or_( + persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern), + persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern), + ) + ) if is_active is not None: conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) @@ -1365,6 +1374,8 @@ class MonitoringService: self, context: TenantContext, session_id: str, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, ) -> dict: """Get bounded session details with full statistics computed in SQL.""" workspace_uuid = require_workspace_uuid(context) @@ -1478,12 +1489,17 @@ class MonitoringService: ) ) tool_stats = tool_stats_result.one() + tool_conditions = [ + persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, + persistence_monitoring.MonitoringToolCall.session_id == session_id, + ] + if start_time is not None: + tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) + if end_time is not None: + tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time) tool_query = ( sqlalchemy.select(persistence_monitoring.MonitoringToolCall) - .where( - persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, - persistence_monitoring.MonitoringToolCall.session_id == session_id, - ) + .where(*tool_conditions) .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) .limit(detail_limit + 1) ) diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 9a10ea61a..cf4608e65 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint: assert response.status_code == 200 + @pytest.mark.asyncio + async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app): + fake_monitoring_app.monitoring_service.get_sessions.reset_mock() + + response = await quart_test_client.get( + '/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40', + headers={'Authorization': 'Bearer test_token'}, + ) + + assert response.status_code == 200 + kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs + assert kwargs['bot_ids'] == ['bot-1'] + assert kwargs['user_query'] == 'alice' + assert kwargs['limit'] == 20 + assert kwargs['offset'] == 40 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringErrorsEndpoint: @@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints: """Tests for detail endpoints.""" @pytest.mark.asyncio - async def test_get_session_analysis(self, quart_test_client): + async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app): """GET /api/v1/monitoring/sessions/{id}/analysis.""" response = await quart_test_client.get( - '/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/sessions/sess-1/analysis' + '?startTime=2026-08-31T16%3A00%3A00.000Z' + '&endTime=2026-09-01T15%3A59%3A59.999Z', + headers={'Authorization': 'Bearer test_token'}, ) assert response.status_code == 200 + kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs + assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00' + assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000' @pytest.mark.asyncio async def test_get_message_details(self, quart_test_client): diff --git a/tests/unit_tests/api/service/test_monitoring_tenancy.py b/tests/unit_tests/api/service/test_monitoring_tenancy.py index a24d937ff..2e2b4216f 100644 --- a/tests/unit_tests/api/service/test_monitoring_tenancy.py +++ b/tests/unit_tests/api/service/test_monitoring_tenancy.py @@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service): assert (await service.get_message_details(context_a, message_b))['found'] is False +async def test_session_search_matches_user_id_or_name_within_workspace(service): + context_a = _context(WORKSPACE_A) + context_b = _context(WORKSPACE_B) + fixtures = [ + (context_a, 'session-id-match', 'customer-42', 'Alice'), + (context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'), + (context_a, 'session-no-match', 'customer-7', 'Bob'), + (context_b, 'session-other-workspace', 'customer-42', 'Alice'), + ] + for context, session_id, user_id, user_name in fixtures: + await service.record_session_start( + context, + session_id=session_id, + bot_id='same-bot', + bot_name='Same Bot', + pipeline_id='same-pipeline', + pipeline_name='Same Pipeline', + user_id=user_id, + user_name=user_name, + ) + + by_id, id_total = await service.get_sessions(context_a, user_query='customer-42') + by_name, name_total = await service.get_sessions(context_a, user_query='alice') + + assert id_total == 1 + assert [session['session_id'] for session in by_id] == ['session-id-match'] + assert name_total == 2 + assert {session['session_id'] for session in by_name} == { + 'session-id-match', + 'session-name-match', + } + + async def test_tool_call_inherits_context_from_connection_message_row(service): context = _context(WORKSPACE_A) message_id = await _record_message(service, context, 'tool context') diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 3a181134a..5b31e2e9b 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -17,6 +17,7 @@ import { Copy, Check, ChevronDown, + ChevronLeft, ChevronRight, Workflow, ThumbsUp, @@ -117,16 +118,43 @@ interface BotSessionMonitorProps { botId: string; } +const SESSION_PAGE_SIZE = 20; +const MESSAGE_PAGE_SIZE = 50; + +const localDateBoundaryToISOString = ( + dateValue: string, + endOfDay: boolean, +): string => { + const [year, month, day] = dateValue.split('-').map(Number); + return new Date( + year, + month - 1, + day, + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + endOfDay ? 999 : 0, + ).toISOString(); +}; + const BotSessionMonitor = forwardRef< BotSessionMonitorHandle, BotSessionMonitorProps >(function BotSessionMonitor({ botId }, ref) { const { t } = useTranslation(); const [sessions, setSessions] = useState([]); + const [sessionTotal, setSessionTotal] = useState(0); + const [sessionPage, setSessionPage] = useState(0); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [userQuery, setUserQuery] = useState(''); + const [appliedUserQuery, setAppliedUserQuery] = useState(''); const [selectedSessionId, setSelectedSessionId] = useState( null, ); const [messages, setMessages] = useState([]); + const [messageTotal, setMessageTotal] = useState(0); + const [messagePage, setMessagePage] = useState(0); const [loadingSessions, setLoadingSessions] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false); @@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef< Record >({}); const messagesContainerRef = useRef(null); + const sessionRequestIdRef = useRef(0); + const messageRequestIdRef = useRef(0); const { admins, reload: reloadAdmins } = useBotAdmins(botId); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [togglingAdmin, setTogglingAdmin] = useState(null); @@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef< }; const loadSessions = useCallback(async () => { + const requestId = ++sessionRequestIdRef.current; setLoadingSessions(true); try { - const response = await httpClient.getBotSessions(botId); + const response = await httpClient.getBotSessions(botId, { + limit: SESSION_PAGE_SIZE, + offset: sessionPage * SESSION_PAGE_SIZE, + startTime: startDate + ? localDateBoundaryToISOString(startDate, false) + : undefined, + endTime: endDate + ? localDateBoundaryToISOString(endDate, true) + : undefined, + userQuery: appliedUserQuery || undefined, + }); + if (requestId !== sessionRequestIdRef.current) return; setSessions(response.sessions ?? []); + setSessionTotal(response.total ?? 0); } catch (error) { - console.error('Failed to load sessions:', error); + if (requestId === sessionRequestIdRef.current) { + console.error('Failed to load sessions:', error); + } } finally { - setLoadingSessions(false); + if (requestId === sessionRequestIdRef.current) { + setLoadingSessions(false); + } } - }, [botId]); + }, [appliedUserQuery, botId, endDate, sessionPage, startDate]); useImperativeHandle( ref, @@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef< ); const loadMessages = useCallback( - async (sessionId: string) => { + async (sessionId: string, page: number) => { + const requestId = ++messageRequestIdRef.current; setLoadingMessages(true); setExpandedToolCallIds({}); try { - const messagesRes = await httpClient.getSessionMessages(sessionId); + const messagesRes = await httpClient.getSessionMessages( + sessionId, + MESSAGE_PAGE_SIZE, + page * MESSAGE_PAGE_SIZE, + ); + if (requestId !== messageRequestIdRef.current) return; const sorted = (messagesRes.messages ?? []).sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); setMessages(sorted); + setMessageTotal(messagesRes.total ?? 0); try { + const analysisParams = new URLSearchParams(); + if (sorted.length > 0) { + analysisParams.set('startTime', sorted[0].timestamp); + analysisParams.set('endTime', sorted[sorted.length - 1].timestamp); + } const analysisRes = await httpClient.get<{ tool_calls?: SessionToolCall[]; }>( - `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, + `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`, ); + if (requestId !== messageRequestIdRef.current) return; setToolCalls(analysisRes?.tool_calls ?? []); } catch (analysisError) { + if (requestId !== messageRequestIdRef.current) return; console.error('Failed to load session tool calls:', analysisError); setToolCalls([]); } @@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef< }>( `/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`, ); + if (requestId !== messageRequestIdRef.current) return; const map: Record = {}; if (feedbackRes?.feedback) { @@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef< setFeedbackMap({}); } } catch (error) { - console.error('Failed to load session messages:', error); + if (requestId === messageRequestIdRef.current) { + console.error('Failed to load session messages:', error); + } } finally { - setLoadingMessages(false); + if (requestId === messageRequestIdRef.current) { + setLoadingMessages(false); + } } }, [botId], @@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef< loadSessions(); }, [loadSessions]); + useEffect(() => { + setSelectedSessionId(null); + setMessagePage(0); + }, [appliedUserQuery, botId, endDate, sessionPage, startDate]); + useEffect(() => { if (selectedSessionId) { - loadMessages(selectedSessionId); + loadMessages(selectedSessionId, messagePage); } else { + messageRequestIdRef.current += 1; + setLoadingMessages(false); setMessages([]); + setMessageTotal(0); setToolCalls([]); setExpandedToolCallIds({}); setFeedbackMap({}); } - }, [selectedSessionId, loadMessages]); + }, [selectedSessionId, messagePage, loadMessages]); useEffect(() => { if (messages.length === 0 && toolCalls.length === 0) return; @@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef< const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); + const sessionPageCount = Math.max( + 1, + Math.ceil(sessionTotal / SESSION_PAGE_SIZE), + ); + const messagePageCount = Math.max( + 1, + Math.ceil(messageTotal / MESSAGE_PAGE_SIZE), + ); + + const applyUserSearch = () => { + setSessionPage(0); + setAppliedUserQuery(userQuery.trim()); + }; return ( <> @@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef< )} + + {t('bots.sessionMonitor.totalSessions', { + defaultValue: '{{count}} sessions', + count: sessionTotal, + })} + + +
+
+ setUserQuery(event.target.value)} + onKeyDown={(event) => + event.key === 'Enter' && applyUserSearch() + } + aria-label={t('bots.sessionMonitor.userSearch', { + defaultValue: 'User ID or name', + })} + placeholder={t('bots.sessionMonitor.userSearch', { + defaultValue: 'User ID or name', + })} + className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs" + /> + +
+
+ { + setSessionPage(0); + setStartDate(event.target.value); + }} + aria-label={t('bots.sessionMonitor.startDate', { + defaultValue: 'Start date', + })} + className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]" + /> + { + setSessionPage(0); + setEndDate(event.target.value); + }} + aria-label={t('bots.sessionMonitor.endDate', { + defaultValue: 'End date', + })} + className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]" + /> +
{/* Session List */} @@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef< 'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer', isSelected ? 'bg-accent' : 'hover:bg-accent/50', )} - onClick={() => setSelectedSessionId(session.session_id)} + onClick={() => { + setSelectedSessionId(session.session_id); + setMessagePage(0); + }} >
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
)}
+
+ + + {sessionPage + 1} / {sessionPageCount} + + +
{/* Right Panel: Messages */} @@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef< )} +
+ + + {messagePage + 1} / {messagePageCount} · {messageTotal} + + +
)} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 4a57956ea..32f6c9b66 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient { public getBotSessions( botId: string, - limit: number = 100, - offset: number = 0, + options: { + limit: number; + offset: number; + startTime?: string; + endTime?: string; + userQuery?: string; + }, ): Promise<{ sessions: Array<{ session_id: string; @@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient { }> { const queryParams = new URLSearchParams(); queryParams.append('botId', botId); - queryParams.append('limit', limit.toString()); - queryParams.append('offset', offset.toString()); + queryParams.append('limit', options.limit.toString()); + queryParams.append('offset', options.offset.toString()); + if (options.startTime) { + queryParams.append('startTime', options.startTime); + } + if (options.endTime) { + queryParams.append('endTime', options.endTime); + } + if (options.userQuery) { + queryParams.append('userQuery', options.userQuery); + } return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); } diff --git a/web/tests/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs new file mode 100644 index 000000000..f855237f4 --- /dev/null +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const repoRoot = path.resolve(root, '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); +const includes = (source, token, message) => + assert.ok(source.includes(token), message); + +test('session list request supports a server-side page and operator filters', () => { + const client = read('src/app/infra/http/BackendClient.ts'); + includes(client, 'startTime?: string', 'client accepts a start date'); + includes(client, 'endTime?: string', 'client accepts an end date'); + includes(client, 'userQuery?: string', 'client accepts a user query'); + includes( + client, + "queryParams.append('offset', options.offset.toString())", + 'client sends the requested session offset', + ); + includes( + client, + "queryParams.append('userQuery', options.userQuery)", + 'client sends the user query', + ); + + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'SESSION_PAGE_SIZE', + 'sessionTotal', + 'sessionPage', + 'startDate', + 'endDate', + 'userQuery', + ]) { + includes(monitor, token, `monitor includes ${token}`); + } +}); + +test('session detail requests and renders a bounded message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'MESSAGE_PAGE_SIZE', + 'messageTotal', + 'messagePage', + 'page * MESSAGE_PAGE_SIZE', + ]) { + includes(monitor, token, `message pagination includes ${token}`); + } +}); + +test('backend filters sessions by user id or user name in the existing endpoint', () => { + const controller = readRepo( + 'src/langbot/pkg/api/http/controller/groups/monitoring.py', + ); + const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py'); + includes( + controller, + "quart.request.args.get('userQuery')", + 'route accepts userQuery', + ); + includes(controller, 'user_query=user_query', 'route forwards userQuery'); + includes( + service, + 'user_query: str | None = None', + 'service accepts userQuery', + ); + includes( + service, + 'MonitoringSession.user_id.ilike', + 'service searches user ids', + ); + includes( + service, + 'MonitoringSession.user_name.ilike', + 'service searches user names', + ); +}); + +test('stale session and message page responses cannot overwrite the latest page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'sessionRequestIdRef', + 'messageRequestIdRef', + 'requestId !== sessionRequestIdRef.current', + 'requestId !== messageRequestIdRef.current', + 'messageRequestIdRef.current += 1', + ]) { + includes(monitor, token, `stale response guard includes ${token}`); + } +}); + +test('changing the session page or filters clears the selected detail', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared'); + includes( + monitor, + '[appliedUserQuery, botId, endDate, sessionPage, startDate]', + 'page and filters invalidate the selected session', + ); +}); + +test('date filters use the operator local calendar day', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes( + monitor, + 'localDateBoundaryToISOString(startDate, false)', + 'local start-of-day conversion', + ); + includes( + monitor, + 'localDateBoundaryToISOString(endDate, true)', + 'local end-of-day conversion', + ); +}); + +test('session tool calls are bounded to the visible message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, "analysisParams.set('startTime'", 'analysis page start'); + includes(monitor, "analysisParams.set('endTime'", 'analysis page end'); +}); From 8cf0015502a606ca18b4bbe77bc65cb5f9e11f94 Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 17:56:02 +0800 Subject: [PATCH 03/56] fix(dingtalk): restore card auto layout (#2491) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/libs/dingtalk_api/api.py | 5 ++- .../unit_tests/platform/test_dingtalk_api.py | 43 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index c0a7492ad..6ead2027d 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -697,9 +697,10 @@ class DingTalkClient: if not await self.check_access_token(): await self.get_access_token() - cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)} + template_params = dict(card_param_map or {}) if card_data_config is not None: - cardData['config'] = json.dumps(card_data_config) + template_params['config'] = card_data_config + cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)} body: dict = { 'cardTemplateId': card_template_id, diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py index 03c84e97b..450128f3c 100644 --- a/tests/unit_tests/platform/test_dingtalk_api.py +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -1,8 +1,11 @@ """Tests for DingTalk API payload helpers.""" import json +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock -from langbot.libs.dingtalk_api.api import _stringify_card_param_map +from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map +from langbot.pkg.utils import httpclient def test_dingtalk_card_param_map_stringifies_select_component_arrays(): @@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures(): assert params['other'] == '["A"]' assert params['empty'] == '' + + +async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch): + response = type('Response', (), {'status_code': 200})() + post = AsyncMock(return_value=response) + + @asynccontextmanager + async def client_context(): + yield type('HttpClient', (), {'post': post})() + + client = object.__new__(DingTalkClient) + client.access_token = 'access-token' + client.robot_code = 'robot-code' + client.key = 'client-id' + client.logger = None + client.check_access_token = AsyncMock(return_value=True) + client._http_client_context = client_context + monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}')) + + original_params = {'content': 'hello'} + delivered = await client.create_and_deliver_card( + card_template_id='template-id', + out_track_id='track-id', + open_space_id='dtv1.card//IM_ROBOT.user-id', + is_group=False, + card_param_map=original_params, + card_data_config={'autoLayout': True}, + ) + + request_body = post.await_args.kwargs['json'] + assert delivered is True + assert request_body['cardData'] == { + 'cardParamMap': { + 'content': 'hello', + 'config': '{"autoLayout": true}', + } + } + assert original_params == {'content': 'hello'} From 5c49cb60e31bc92f6bd7a95d7b485af38bfa339d Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 20:11:21 +0800 Subject: [PATCH 04/56] fix(embed): preserve replies after empty assistant frames (#2492) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/templates/embed/widget.js | 7 +- .../embed-widget-assistant-dedupe.test.mjs | 201 ++++++++++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 web/tests/unit/embed-widget-assistant-dedupe.test.mjs diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js index a62c54a4c..f026ea900 100644 --- a/src/langbot/templates/embed/widget.js +++ b/src/langbot/templates/embed/widget.js @@ -642,9 +642,10 @@ .replace(/\s+/g, " ") .trim(); if ( - prevContent === content || - prevContent.indexOf(content) >= 0 || - content.indexOf(prevContent) >= 0 + prevContent && + (prevContent === content || + prevContent.indexOf(content) >= 0 || + content.indexOf(prevContent) >= 0) ) return; } diff --git a/web/tests/unit/embed-widget-assistant-dedupe.test.mjs b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs new file mode 100644 index 000000000..42f172d3d --- /dev/null +++ b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const widgetPath = path.resolve( + testDirectory, + '../../../src/langbot/templates/embed/widget.js', +); +const widgetSource = fs.readFileSync(widgetPath, 'utf8'); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.className = ''; + this.dataset = {}; + this.style = {}; + this.listeners = {}; + this._innerHTML = ''; + } + + appendChild(child) { + this.children.push(child); + return child; + } + + setAttribute(name, value) { + this[name] = String(value); + } + + addEventListener(event, listener) { + this.listeners[event] = listener; + } + + click() { + this.listeners.click?.({}); + } + + attachShadow() { + this.shadowRoot = new FakeElement('shadow-root'); + return this.shadowRoot; + } + + get classList() { + return { + add: (...names) => { + const classes = `${this.className} ${names.join(' ')}` + .trim() + .split(/\s+/); + this.className = [...new Set(classes)].join(' '); + }, + }; + } + + set textContent(value) { + this._innerHTML = String(value ?? ''); + } + + set innerHTML(value) { + this._innerHTML = String(value ?? ''); + } + + get innerHTML() { + return this._innerHTML; + } + + querySelectorAll(selector) { + const matches = []; + for (const child of this.children) { + if ( + selector.startsWith('.') && + child.className.split(/\s+/).includes(selector.slice(1)) + ) { + matches.push(child); + } + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement('body'); + this.head = new FakeElement('head'); + this.readyState = 'complete'; + this.currentScript = { getAttribute: () => null }; + } + + createElement(tagName) { + return new FakeElement(tagName); + } + + getElementById(id) { + const find = (element) => { + if (element.id === id) return element; + for (const child of element.children) { + const match = find(child); + if (match) return match; + } + return null; + }; + return find(this.body) ?? find(this.head); + } +} + +class FakeWebSocket { + static OPEN = 1; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.OPEN; + FakeWebSocket.instances.push(this); + } + + send() {} +} + +function launchWidget() { + FakeWebSocket.instances = []; + const document = new FakeDocument(); + const window = { + crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' }, + sessionStorage: { getItem: () => null, setItem: () => {} }, + }; + const context = vm.createContext({ + document, + window, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + WebSocket: FakeWebSocket, + fetch: () => new Promise(() => {}), + requestAnimationFrame: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + }); + + vm.runInContext(widgetSource, context, { filename: widgetPath }); + const root = document.getElementById('langbot-widget-root'); + assert.ok(root, 'widget should initialize'); + root.shadowRoot.querySelector('.lb-bubble').click(); + const socket = FakeWebSocket.instances.at(-1); + assert.ok(socket, 'opening widget should connect its WebSocket'); + + return { + receive(message) { + socket.onmessage({ + data: JSON.stringify({ type: 'response', data: message }), + }); + }, + assistantMessages() { + return root.shadowRoot.querySelectorAll('.lb-msg-assistant'); + }, + }; +} + +function assistant(id, content) { + return { id, role: 'assistant', content, is_final: true }; +} + +test('renders a non-empty reply after an empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('thought', '')); + widget.receive(assistant('answer', 'visible answer')); + + const messages = widget.assistantMessages(); + assert.equal(messages.length, 2); + assert.equal( + messages[1].querySelector('.lb-msg-bubble').innerHTML, + 'visible answer', + ); +}); + +test('still drops a duplicate non-empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'same answer')); + widget.receive(assistant('answer-2', 'same answer')); + + assert.equal(widget.assistantMessages().length, 1); +}); + +test('still keeps two distinct non-empty assistant frames', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'first answer')); + widget.receive(assistant('answer-2', 'second answer')); + + assert.equal(widget.assistantMessages().length, 2); +}); From 5ca30133a3c468cb782c46c265147211bf665cb5 Mon Sep 17 00:00:00 2001 From: mintya <931108724@qq.com> Date: Tue, 1 Sep 2026 21:24:54 +0800 Subject: [PATCH 05/56] fix(lark): stop duplicating final reply text in streaming card (#2490) * fix(lark): stop duplicating final reply text in streaming card * fix(lark): stop duplicating final reply text in streaming card --- src/langbot/pkg/platform/sources/lark.py | 36 +++++- .../unit_tests/platform/test_lark_adapter.py | 121 +++++++++++++++++- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 3c3476159..085fdc57a 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -160,6 +160,29 @@ def _lark_should_update_stream_element( return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) +def _lark_final_layout_texts( + *, + resume_from: bool, + text_message: str, + pre_pause_cached: str | None, + resume_cached: str, +) -> tuple[str, str]: + """Return (main_text, resume_placeholder_text) for the final card update. + + Non-resume round: the full reply belongs in the main streaming element + only — also rendering the resume placeholder duplicates the reply, since + both hold the same accumulated text. Resume round (Dify HITL): keep the + pre-pause text in the main element and the resumed text in the + placeholder, as they are distinct segments. + """ + if resume_from: + # An empty pre-pause cache is valid (Dify paused before emitting any + # text); only a missing entry (None) falls back to the full text. + main_text = text_message if pre_pause_cached is None else pre_pause_cached + return main_text, resume_cached + return text_message, '' + + def _lark_display_input_value(field: dict, value: typing.Any) -> str: field_type = _dify_field_type(field) if field_type == 'file': @@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) else: - # Normal finish: keep pre-pause + resume content visible, - # remove buttons/notice, drop the resume placeholder. + # Normal finish: remove buttons/notice and finalize the card. + main_text, resume_text = _lark_final_layout_texts( + resume_from=resume_from, + text_message=text_message, + pre_pause_cached=self.card_pre_pause_text.get(card_id), + resume_cached=resume_cached, + ) await self._update_card_layout( card_id=card_id, message_source=message_source, - text_message=pre_pause, + text_message=main_text, sequence=final_seq, form_data=None, notice_text=selected_notice if resume_from else '', - resume_placeholder_text=resume_cached, + resume_placeholder_text=resume_text, ) self._drop_card_state(card_id) self.card_id_dict.pop(message_id, None) diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py index a8e1fe3d9..a89709b75 100644 --- a/tests/unit_tests/platform/test_lark_adapter.py +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -1,7 +1,7 @@ """Tests for Lark adapter helper behavior.""" import threading -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import ( _lark_completed_input_lines, _lark_current_input_defs, _lark_extract_action_form_inputs, + _lark_final_layout_texts, _lark_should_update_stream_element, _lark_visible_form_content, ) @@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object(): ) assert lines == ['✅ xiala:B'] + + +def test_lark_final_layout_texts_normal_round_drops_resume_placeholder(): + """Non-resume final chunk: the reply must land in the main element only. + + Regression: rendering the resume placeholder too duplicated the reply, + because the accumulated streaming text equals the final text on a normal + round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card). + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=False, + text_message='It is Sep 1, 2026, 15:09:15.', + pre_pause_cached=None, + resume_cached='It is Sep 1, 2026, 15:09:15.', + ) + + assert main_text == 'It is Sep 1, 2026, 15:09:15.' + assert resume_text == '' + + +def test_lark_final_layout_texts_resume_round_keeps_both_segments(): + """Dify HITL resume final chunk: pre-pause text and resumed text differ, + both segments stay visible.""" + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='partial answer before pause', + resume_cached='resumed answer', + ) + + assert main_text == 'partial answer before pause' + assert resume_text == 'resumed answer' + + +def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back(): + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='answer', + pre_pause_cached=None, + resume_cached='answer', + ) + + assert main_text == 'answer' + assert resume_text == 'answer' + + +def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty(): + """Dify paused before emitting any text: the pre-pause cache is a valid + empty string and must NOT be treated as a cache miss. + + Regression: `pre_pause_cached or text_message` fell back to the full + text, so the final card rendered ('resumed answer', 'resumed answer') + and duplicated the reply. + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='', + resume_cached='resumed answer', + ) + + assert main_text == '' + assert resume_text == 'resumed answer' + + +def _build_resume_final_chunk_adapter(message_text: str): + """Build a LarkAdapter whose card state mimics a Dify HITL round that + paused before emitting any text, then resumed and completed.""" + adapter = LarkAdapter.model_construct( + api_client=MagicMock(), + message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))), + ) + adapter.config = {'app_type': 'self'} + LarkAdapter.get_app_access_token = lambda self: None + LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None + adapter.card_id_dict = {'msg-1': 'card-1'} + adapter.card_streaming_text = {'card-1': message_text} + adapter.card_pre_pause_text = {'card-1': ''} + adapter.card_resume_transitioned = {'card-1'} + adapter.card_sequence_dict = {} + adapter.card_last_accessed = {} + adapter.card_cleanup_at = 0.0 + adapter.card_id_to_source_ids = {} + adapter.reply_message_card_ids = {} + adapter.card_form_content = {} + adapter.card_form_input_defs = {} + adapter.card_form_inputs = {} + adapter._update_card_layout = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty(): + """End-to-end regression via reply_message_chunk: Dify paused before any + text, so the pre-pause cache is ''. The final card update must render the + resumed answer only once (empty main text + resume placeholder), not + twice as ('resumed answer', 'resumed answer').""" + adapter = _build_resume_final_chunk_adapter('resumed answer') + + bot_message = MagicMock( + resp_message_id='msg-1', + msg_sequence=1, + spec=['resp_message_id', 'msg_sequence', '_resume_from_form'], + ) + bot_message._resume_from_form = True + message_source = MagicMock(source_platform_object=None) + + await adapter.reply_message_chunk( + message_source, + bot_message, + MagicMock(), + is_final=True, + ) + + adapter._update_card_layout.assert_awaited_once() + layout_kwargs = adapter._update_card_layout.await_args.kwargs + assert layout_kwargs['text_message'] == '' + assert layout_kwargs['resume_placeholder_text'] == 'resumed answer' From 601c6975ea1937d7069ee8d720bb1ad70e960f19 Mon Sep 17 00:00:00 2001 From: CWT <127104378+zx90316@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:35 +0800 Subject: [PATCH 06/56] fix(ollama): use litellm's ollama_chat provider for native tool-calling (#2494) The Ollama requester declared litellm_provider: ollama, which routes every request through litellm's legacy /api/generate-based OllamaConfig. That config's get_supported_openai_params() does not include "tools"/"tool_choice" at all, so an Ollama-hosted model in a local-agent pipeline could never receive a structured tool definition or return a structured tool_calls response - it could only try to express a tool call as free text (typically inside its own reasoning), which LangBot then has no way to execute. litellm's "ollama_chat" provider targets Ollama's modern /api/chat endpoint instead, which correctly forwards tools/tool_choice and correctly surfaces the model's native message.tool_calls field. Verified against a real local Ollama 0.33.2 instance with the exact system prompt, RAG-augmented user message, and tool set a live pipeline sends. Two follow-on fixes needed because the Ollama requester definition is shared by LLM and text-embedding models: - get_reasoning_capabilities: match family in ('ollama', 'ollama_chat') so the reasoning-level UI still works for this provider. - scan_models: retry {base_url}/v1/models on a 404 from {base_url}/models, since Ollama's base_url is a bare host (must not include /v1 - that would break OllamaChatConfig.get_complete_url, which appends /api/chat to it directly), unlike most other OpenAI-compatible providers whose base_url already ends in /v1. - invoke_embedding: litellm's embedding routing has no "ollama_chat" case, only "ollama". Build the embedding model name with an explicit custom_llm_provider="ollama" override when the requester is configured for ollama_chat, so embedding models (e.g. bge-m3) keep working. Co-authored-by: zx90316 --- .../provider/modelmgr/requesters/litellmchat.py | 17 +++++++++++++++-- .../modelmgr/requesters/ollamachat.yaml | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index e33f7fd03..138199072 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): levels = ['provider_default', 'disabled', 'enabled'] elif family == 'doubao': levels = ['provider_default', 'disabled', 'low', 'medium', 'high'] - elif family == 'ollama': + elif family in ('ollama', 'ollama_chat'): levels = ['provider_default'] levels.append('disabled') if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: @@ -1345,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester): extra_args: dict[str, typing.Any] = {}, ) -> tuple[list[list[float]], dict]: """Invoke embedding and return vectors with usage info.""" - model_name = self._build_litellm_model_name(model.model_entity.name) + # litellm's embedding routing has no "ollama_chat" branch (that provider + # exists only for /api/chat completions) — embeddings still go through + # the plain "ollama" provider. Requesters configured for ollama_chat + # (to get native tool-calling on the chat path) must fall back to + # "ollama" here specifically, or embedding calls raise "Unmapped LLM + # provider for this endpoint". + embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None + model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider) api_key = model.provider.token_mgr.get_token() args = { @@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester): event_hooks=httpclient.httpx_response_limit_hooks(), ) as client: response = await client.get(models_url, headers=headers) + if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'): + # Some OpenAI-compatible servers (notably a bare Ollama host, + # e.g. http://host:11434) expose the model list under /v1/models + # rather than /models. Providers whose configured base_url + # already ends in /v1 keep their original (working) URL. + response = await client.get(f'{base_url}/v1/models', headers=headers) response.raise_for_status() payload = await httpclient.parse_json_response(response) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml index 83e116c8f..cf9441af9 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml @@ -7,7 +7,7 @@ metadata: zh_Hans: Ollama icon: ollama.svg spec: - litellm_provider: ollama + litellm_provider: ollama_chat config: - name: base_url label: From 7b7d3f04e8c8647cefc8aad85ac91e6e8d9a06bb Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 2 Sep 2026 21:22:14 +0800 Subject: [PATCH 07/56] feat(box): support explicit host backend (#2498) --- skills/skills.index.json | 3 ++- skills/skills/langbot-deploy/SKILL.md | 8 +++++++- .../cases/sandbox-skill-authoring-e2e.yaml | 5 +++-- .../references/sandbox-skill-authoring.md | 7 +++++-- .../sandbox-native-tools-unavailable.yaml | 3 ++- src/langbot/pkg/box/service.py | 13 ++++++++++--- src/langbot/pkg/provider/tools/loaders/native.py | 1 + .../pkg/provider/tools/loaders/skill_authoring.py | 3 ++- src/langbot/templates/config.yaml | 5 ++++- 9 files changed, 36 insertions(+), 12 deletions(-) diff --git a/skills/skills.index.json b/skills/skills.index.json index 640996adc..b109490b3 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -1349,7 +1349,8 @@ "local-agent", "tools", "e2b", - "nsjail" + "nsjail", + "host" ], "automation": "", "setup_automation": [], diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md index e03182e01..b26ef13a0 100644 --- a/skills/skills/langbot-deploy/SKILL.md +++ b/skills/skills/langbot-deploy/SKILL.md @@ -63,7 +63,7 @@ Key settings: | `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. | | `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) | | `box.enabled` | Master switch for the Box sandbox runtime | -| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` | +| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` | | `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed | Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`). @@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`). with `--standalone-runtime`. - Box has a parallel `--standalone-box` flag; the Docker box host is `langbot_box:5410`. +- `box.backend: host` runs commands directly as the Box Runtime system user. + It is never auto-selected, provides no sandbox isolation, and is only for + trusted local development. A WebSocket-controlled host backend requires + `LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed. ## Global API key — enabling for agents/automation @@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`. - "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running usually means the user isn't in the `docker` group → `sudo usermod -aG docker ` and restart in a new shell. +- Do not use `box.backend: host` as a production fallback. It cannot enforce + image, filesystem, network, PID, CPU, memory, or storage isolation. - Box root host/container path mismatch breaks sandbox container creation. - Don't commit a non-empty `api.global_api_key` to version control. diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml index 91bb97fd3..608266c4e 100644 --- a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml @@ -13,6 +13,7 @@ tags: - tools - e2b - nsjail + - host skills: - langbot-env-setup - langbot-testing @@ -23,7 +24,7 @@ env: - LANGBOT_LOCAL_AGENT_PIPELINE_NAME preconditions: - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." - - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail." + - "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode." - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." steps: - "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend." @@ -33,7 +34,7 @@ steps: checks: - "UI: Debug Chat final assistant response contains E2E_OK:." - "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path." - - "Logs: The selected backend name is the expected one, such as e2b or nsjail." + - "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host." - "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md." - "Box status: recent_error_count is 0 after the run." evidence_required: diff --git a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md index db9b82647..1d4b9a02f 100644 --- a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md +++ b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md @@ -4,7 +4,7 @@ Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat. -This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence. +This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence. ## Preconditions @@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag - `BOX_BACKEND=e2b` when validating E2B. - `BOX_BACKEND=nsjail` when validating nsjail. - `BOX_BACKEND=local` or `docker` when validating local container fallback. + - `BOX_BACKEND=host` only when validating explicit, trusted local direct execution. 3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name. 4. Confirm Debug Chat uses a model with function-calling ability. 5. Confirm backend logs say native sandbox tools are available. @@ -71,7 +72,7 @@ Backend logs should show: - `register_skill` - `activate` - a second `exec` whose workdir is `/workspace/.skills/` -- `backend=e2b`, `backend=nsjail`, or the expected local backend +- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend After the run, verify the skill store through the UI or API: @@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss - Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible. - nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`. - On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox. +- The host backend does not honor sandbox image, network, rootfs, process, or + resource isolation. Use a disposable workspace and low-privilege account. - If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed. ## Related Troubleshooting diff --git a/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml index 6c6106036..e5da2dbcb 100644 --- a/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml +++ b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml @@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured date: 2026-05-18 symptoms: - "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available." - - "The Box runtime later reports that E2B, nsjail, or Docker is configured." + - "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured." - "Debug Chat does not expose exec, register_skill, or activate as usable tools." patterns: - "Native sandbox tools ... are NOT available" @@ -19,6 +19,7 @@ fix_steps: - "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty." - "For E2B, verify the key without printing it and confirm any required template setting." - "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process." + - "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback." verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend." related_cases: - sandbox-skill-authoring-e2e diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index c8554cc52..bf626090b 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -455,7 +455,9 @@ class BoxService: async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None: if not self._available: - raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.') + raise BoxError( + 'Box runtime is not available. Configure an available Box backend before using Box features.' + ) if self._cloud_managed: if self._admission is None: raise BoxAdmissionError('Cloud Box sandbox admission is unavailable') @@ -565,7 +567,9 @@ class BoxService: skip_host_mount_validation: bool = False, ) -> dict: if not self._available: - raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.') + raise BoxError( + 'Box runtime is not available. Configure an available Box backend before using Box features.' + ) execution_context = await self._validated_execution_context(self._query_execution_context(query)) spec_payload = self._managed_policy_payload(execution_context, spec_payload) await self._require_validated_workspace_sandbox(execution_context) @@ -2142,5 +2146,8 @@ class BoxService: if backend_name: payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable' else: - payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available' + payload['connector_error'] = ( + 'No supported sandbox backend (Docker / nsjail / E2B) is available. ' + 'Trusted local development may explicitly select the unsafe host backend.' + ) return payload diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index d2dfdc969..e51195678 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader): self.ap.logger.warning( 'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. ' 'No sandbox backend (Docker/nsjail/E2B) is ready. ' + 'Trusted local development may explicitly select box.backend=host. ' 'The LLM will not have access to code execution or file operation tools.' ) diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 01e297842..5be0dc3e1 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader): else: self.ap.logger.info( 'Skill tools (activate/register_skill) are NOT available. ' - 'No sandbox backend (Docker/nsjail/E2B) is ready.' + 'No sandbox backend (Docker/nsjail/E2B) is ready. ' + 'Trusted local development may explicitly select box.backend=host.' ) async def _check_sandbox_available(self) -> bool: diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index ab98d7af8..6b3c716fa 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -331,7 +331,10 @@ box: # skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still # be listed read-only and http/sse MCP servers continue to work. enabled: true - backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND. + # 'host' runs commands directly as the Box Runtime user without sandbox + # isolation. It is never auto-selected and is only for trusted local + # development. Can be written via BOX__BACKEND. + backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'. runtime: # LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket # runtimes. To protect an exposed endpoint, set the same strong secret From 018dd7a36393bd95453ff1f894c3111266255e96 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 2 Sep 2026 21:41:35 +0800 Subject: [PATCH 08/56] fix(cloud): launch newly registered accounts through Space (#2499) * fix(cloud): launch new accounts through Space * style: format Cloud entry URL * fix(cloud): wait for launch workspace projection --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 38 ++++++++-- src/langbot/pkg/api/http/service/space.py | 5 ++ .../integration/api/test_user_space_oauth.py | 74 ++++++++++++++++++- web/src/app/infra/http/BackendClient.ts | 10 ++- web/src/app/login/page.tsx | 8 +- .../unit/cloud-new-account-entry.test.mjs | 17 +++++ 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 web/tests/unit/cloud-new-account-entry.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 5568047ca..85704d72a 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -9,6 +9,7 @@ from .. import group from .....entity.errors import account as account_errors from ...context import RequestContext from .....cloud.launch import SpaceLaunchError +from .....workspace.errors import WorkspaceNotFoundError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError @@ -143,6 +144,13 @@ class UserRouterGroup(group.RouterGroup): try: redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') + cloud_entry = quart.request.args.get('cloud_entry') == '1' + if ( + cloud_entry + and not launch_workspace_uuid + and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + ): + return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()}) if launch_workspace_uuid: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): return self.fail(1, 'Space launch requires Cloud mode') @@ -429,13 +437,33 @@ class UserRouterGroup(group.RouterGroup): ) account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) + projection_service = self.ap.directory_projection_service + access = None + # A first Cloud launch creates the personal Workspace immediately + # before redirecting here. Pull a bounded number of signed event + # pages until both the Account and its target Workspace membership + # are visible instead of rejecting during the background-sync window. + for attempt in range(4): + if account is not None: + self.ap.user_service._require_active_account(account) + try: + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + account.uuid, + launch['workspace_uuid'], + ) + break + except WorkspaceNotFoundError: + if projection_service is None or attempt == 3: + raise + elif projection_service is None or attempt == 3: + break + + await projection_service.sync_once() + account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) if account is None: raise SpaceLaunchError('Launch Account is not projected into Core') - self.ap.user_service._require_active_account(account) - access = await self.ap.workspace_collaboration_service.resolve_account_workspace( - account.uuid, - launch['workspace_uuid'], - ) + if access is None: # pragma: no cover - bounded loop resolves or raises. + raise SpaceLaunchError('Launch Workspace is not projected into Core') token = await self.ap.user_service.generate_jwt_token(account) return self.success( data={ diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index 5be09a860..174cc965c 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -124,6 +124,11 @@ class SpaceService: params['state'] = state return f'{authorize_url}?{urlencode(params)}' + def get_cloud_entry_url(self) -> str: + """Return the Space-owned Cloud selector for a Cloud Account login.""" + + return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta' + async def exchange_oauth_code( self, code: str, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index be0a9f021..10a48a8c7 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -11,6 +11,7 @@ import pytest import quart from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.integration @@ -27,7 +28,8 @@ async def space_oauth_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() - application.deployment = SimpleNamespace(multi_workspace_enabled=False) + application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss') + application.directory_projection_service = None application.persistence_mgr = None application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.user_service.issue_space_oauth_state = AsyncMock( @@ -69,6 +71,7 @@ async def space_oauth_api(): application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' ) + application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta') application.space_service.exchange_oauth_code = AsyncMock( return_value={ 'access_token': 'space-access-token', @@ -125,6 +128,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau ) +@pytest.mark.asyncio +async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + + response = await client.get( + '/api/v1/user/space/authorize-url', + query_string={ + 'redirect_uri': 'http://localhost/auth/space/callback', + 'cloud_entry': '1', + }, + headers={'Origin': 'http://localhost'}, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta') + application.space_service.get_cloud_entry_url.assert_called_once_with() + application.user_service.issue_space_oauth_state.assert_not_awaited() + + @pytest.mark.asyncio async def test_public_login_rejects_caller_supplied_state(space_oauth_api): application, client = space_oauth_api @@ -414,3 +437,52 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space ) application.user_service.consume_space_oauth_state.assert_not_awaited() application.space_service.exchange_oauth_code.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api): + application, client = space_oauth_api + projected_account = SimpleNamespace( + uuid='account-a', + user='owner@example.com', + account_type='space', + status='active', + ) + application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account]) + application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + assert application.directory_projection_service.sync_once.await_count == 2 + assert application.user_service.get_user_by_uuid.await_count == 3 + + +@pytest.mark.asyncio +async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api): + application, client = space_oauth_api + projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value + application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( + side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access] + ) + application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + application.directory_projection_service.sync_once.assert_awaited_once_with() + assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2 diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 32f6c9b66..41aac21cb 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1385,12 +1385,18 @@ export class BackendClient extends BaseHttpClient { } // ============ Space OAuth API (Redirect Flow) ============ - public getSpaceAuthorizeUrl(redirectUri: string): Promise<{ + public getSpaceAuthorizeUrl( + redirectUri: string, + options?: { cloudEntry?: boolean }, + ): Promise<{ authorize_url: string; }> { return this.get( '/api/v1/user/space/authorize-url', - { redirect_uri: redirectUri }, + { + redirect_uri: redirectUri, + ...(options?.cloudEntry ? { cloud_entry: '1' } : {}), + }, { skipWorkspace: true }, ); } diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 48436017f..061a21988 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -202,7 +202,13 @@ export default function Login() { try { const currentOrigin = window.location.origin; const redirectUri = `${currentOrigin}/auth/space/callback`; - const response = await httpClient.getSpaceAuthorizeUrl(redirectUri); + const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, { + // Cloud Accounts must be launched from Space so a first visit can + // lazily create and project the personal Workspace. Invitation login + // remains on the OAuth callback path because it targets the invited + // Workspace instead. + cloudEntry: !getPendingInvitationToken(), + }); window.location.href = response.authorize_url; } catch { toast.error(t('common.spaceLoginFailed')); diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs new file mode 100644 index 000000000..5420da59e --- /dev/null +++ b/web/tests/unit/cloud-new-account-entry.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/login/page.tsx', import.meta.url), + 'utf8', +); + +test('normal Cloud login enters through the Space Workspace launcher', () => { + assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); + assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/); +}); + +test('invitation login remains on the OAuth callback path', () => { + assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); +}); From c8d8b1aac4aeba90bd88760022fd9c74ce0fe034 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 2 Sep 2026 21:57:53 +0800 Subject: [PATCH 09/56] fix(cloud): serialize directory catch-up (#2500) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 19 +++++- src/langbot/pkg/cloud/directory_projection.py | 17 ++++- .../integration/api/test_user_space_oauth.py | 33 +++++++++ .../cloud/test_directory_projection.py | 67 +++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 85704d72a..af37172db 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -453,13 +453,28 @@ class UserRouterGroup(group.RouterGroup): ) break except WorkspaceNotFoundError: - if projection_service is None or attempt == 3: + if projection_service is None: raise - elif projection_service is None or attempt == 3: + elif projection_service is None: break + if attempt == 3: + break await projection_service.sync_once() account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) + + if access is None and projection_service is not None: + # The target event may be deeper than the bounded incremental + # page budget. One authoritative signed snapshot catches this + # process up without turning the callback into unbounded polling. + await projection_service.refresh_snapshot() + account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) + if account is not None: + self.ap.user_service._require_active_account(account) + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + account.uuid, + launch['workspace_uuid'], + ) if account is None: raise SpaceLaunchError('Launch Account is not projected into Core') if access is None: # pragma: no cover - bounded loop resolves or raises. diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py index 47fad19fd..c5fd2147d 100644 --- a/src/langbot/pkg/cloud/directory_projection.py +++ b/src/langbot/pkg/cloud/directory_projection.py @@ -125,10 +125,21 @@ class DirectoryProjectionService: # The database cursor remains the shared projection high-water mark, # while this cursor tracks what this process has actually observed. self._consumer_cursor: int | None = None + self._sync_lock = asyncio.Lock() async def initialize(self) -> None: """Block Cloud startup until one full signed snapshot is committed.""" + async with self._sync_lock: + await self._refresh_snapshot() + + async def refresh_snapshot(self) -> None: + """Refresh from one full signed snapshot within the sync single-flight.""" + + async with self._sync_lock: + await self._refresh_snapshot() + + async def _refresh_snapshot(self) -> None: last_superseded: _DirectorySnapshotSuperseded | None = None for _attempt in range(5): snapshot = await self.provider.fetch_snapshot(self.instance_uuid) @@ -159,9 +170,13 @@ class DirectoryProjectionService: delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2) async def sync_once(self) -> None: + async with self._sync_lock: + await self._sync_once() + + async def _sync_once(self) -> None: cursor = self._consumer_cursor if cursor is None: - await self.initialize() + await self._refresh_snapshot() return batch = await self.provider.fetch_events( self.instance_uuid, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 10a48a8c7..34c09451b 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -486,3 +486,36 @@ async def test_direct_launch_refreshes_projection_when_account_exists_before_wor assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID application.directory_projection_service.sync_once.assert_awaited_once_with() assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2 + + +@pytest.mark.asyncio +async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api): + application, client = space_oauth_api + projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value + application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( + side_effect=[ + WorkspaceNotFoundError('Workspace not found'), + WorkspaceNotFoundError('Workspace not found'), + WorkspaceNotFoundError('Workspace not found'), + WorkspaceNotFoundError('Workspace not found'), + projected_access, + ] + ) + application.directory_projection_service = SimpleNamespace( + sync_once=AsyncMock(), + refresh_snapshot=AsyncMock(), + ) + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + assert application.directory_projection_service.sync_once.await_count == 3 + application.directory_projection_service.refresh_snapshot.assert_awaited_once_with() + assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5 diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 69619a109..1320a9abc 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import datetime import logging from types import SimpleNamespace @@ -735,6 +736,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte assert second_provider.after_cursors == [1, 2] +async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context): + application, _session_factory = projection_context + + class _ConcurrentProvider(_Provider): + def __init__(self) -> None: + super().__init__([_snapshot(1)]) + self.first_fetch_started = asyncio.Event() + self.release_first_fetch = asyncio.Event() + self.active_fetches = 0 + self.max_active_fetches = 0 + + async def fetch_events( + self, + instance_uuid: str, + after_cursor: int, + limit: int, + ) -> DirectoryEventBatch: + assert instance_uuid == INSTANCE_UUID + assert limit == 100 + self.after_cursors.append(after_cursor) + self.active_fetches += 1 + self.max_active_fetches = max(self.max_active_fetches, self.active_fetches) + try: + if len(self.after_cursors) == 1: + self.first_fetch_started.set() + await self.release_first_fetch.wait() + cursor = after_cursor + 1 + return DirectoryEventBatch( + instance_uuid=instance_uuid, + after_cursor=after_cursor, + cursor=cursor, + high_water_cursor=cursor, + events=( + DirectoryEvent( + cursor=cursor, + uuid=f'40000000-0000-4000-8000-{cursor:012d}', + aggregate_uuid=WORKSPACE_UUID, + event_type='entitlement.changed', + revision=cursor, + payload={ + 'workspace_uuid': WORKSPACE_UUID, + 'entitlement_revision': cursor, + }, + created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC), + ), + ), + ) + finally: + self.active_fetches -= 1 + + provider = _ConcurrentProvider() + service = DirectoryProjectionService(application, provider, INSTANCE_UUID) + await service.initialize() + + first = asyncio.create_task(service.sync_once()) + await provider.first_fetch_started.wait() + second = asyncio.create_task(service.sync_once()) + await asyncio.sleep(0) + provider.release_first_fetch.set() + await asyncio.gather(first, second) + + assert provider.max_active_fetches == 1 + assert provider.after_cursors == [1, 2] + assert service._consumer_cursor == 3 + + async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context): application, session_factory = projection_context event_two = DirectoryEvent( From d50957fc4fb9d2fc259d82404e3161cd26794189 Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 3 Sep 2026 11:44:32 +0800 Subject: [PATCH 10/56] fix(cloud): request automatic Space launch (#2501) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/service/space.py | 2 +- tests/integration/api/test_user_space_oauth.py | 8 ++++++-- tests/unit_tests/api/service/test_space_service.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index 174cc965c..dc6308bc5 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -127,7 +127,7 @@ class SpaceService: def get_cloud_entry_url(self) -> str: """Return the Space-owned Cloud selector for a Cloud Account login.""" - return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta' + return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta&auto_launch=1' async def exchange_oauth_code( self, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 34c09451b..08e137dd2 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -71,7 +71,9 @@ async def space_oauth_api(): application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' ) - application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta') + application.space_service.get_cloud_entry_url = Mock( + return_value='https://space.example/cloud?environment=beta&auto_launch=1' + ) application.space_service.exchange_oauth_code = AsyncMock( return_value={ 'access_token': 'space-access-token', @@ -143,7 +145,9 @@ async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oau ) assert response.status_code == 200 - assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta') + assert (await response.get_json())['data']['authorize_url'] == ( + 'https://space.example/cloud?environment=beta&auto_launch=1' + ) application.space_service.get_cloud_entry_url.assert_called_once_with() application.user_service.issue_space_oauth_state.assert_not_awaited() diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index a77dd14a1..787f30565 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -134,6 +134,18 @@ class TestSpaceServiceGetOAuthAuthorizeUrl: # Verify - uses default URL assert 'https://space.langbot.app/auth/authorize' in result + def test_cloud_entry_url_requests_automatic_launch(self): + """Cloud's login entry must continue through the Space launcher.""" + ap = SimpleNamespace( + instance_config=SimpleNamespace( + data={'space': {'url': 'https://space.example/base/'}}, + ), + ) + + result = SpaceService(ap).get_cloud_entry_url() + + assert result == 'https://space.example/base/cloud?environment=beta&auto_launch=1' + class TestSpaceServiceGetUserByEmail: """Tests for _get_user_by_email internal method.""" From ab52684a01ac9245957df04ac68c8bda2213dae8 Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 3 Sep 2026 12:16:21 +0800 Subject: [PATCH 11/56] Revert "fix(cloud): request automatic Space launch (#2501)" (#2503) This reverts commit d50957fc4fb9d2fc259d82404e3161cd26794189. Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/service/space.py | 2 +- tests/integration/api/test_user_space_oauth.py | 8 ++------ tests/unit_tests/api/service/test_space_service.py | 12 ------------ 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index dc6308bc5..174cc965c 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -127,7 +127,7 @@ class SpaceService: def get_cloud_entry_url(self) -> str: """Return the Space-owned Cloud selector for a Cloud Account login.""" - return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta&auto_launch=1' + return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta' async def exchange_oauth_code( self, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 08e137dd2..34c09451b 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -71,9 +71,7 @@ async def space_oauth_api(): application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' ) - application.space_service.get_cloud_entry_url = Mock( - return_value='https://space.example/cloud?environment=beta&auto_launch=1' - ) + application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta') application.space_service.exchange_oauth_code = AsyncMock( return_value={ 'access_token': 'space-access-token', @@ -145,9 +143,7 @@ async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oau ) assert response.status_code == 200 - assert (await response.get_json())['data']['authorize_url'] == ( - 'https://space.example/cloud?environment=beta&auto_launch=1' - ) + assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta') application.space_service.get_cloud_entry_url.assert_called_once_with() application.user_service.issue_space_oauth_state.assert_not_awaited() diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index 787f30565..a77dd14a1 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -134,18 +134,6 @@ class TestSpaceServiceGetOAuthAuthorizeUrl: # Verify - uses default URL assert 'https://space.langbot.app/auth/authorize' in result - def test_cloud_entry_url_requests_automatic_launch(self): - """Cloud's login entry must continue through the Space launcher.""" - ap = SimpleNamespace( - instance_config=SimpleNamespace( - data={'space': {'url': 'https://space.example/base/'}}, - ), - ) - - result = SpaceService(ap).get_cloud_entry_url() - - assert result == 'https://space.example/base/cloud?environment=beta&auto_launch=1' - class TestSpaceServiceGetUserByEmail: """Tests for _get_user_by_email internal method.""" From b44b8f474d4bdc5f99a85a5994b5e8bb4412d5bd Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 3 Sep 2026 23:14:44 +0800 Subject: [PATCH 12/56] fix(cloud): provision login workspace just in time (#2505) * fix(cloud): provision login workspace just in time * fix(oauth): send callback URI during code exchange * fix(oauth): preserve callback URI through browser exchange * fix(oauth): negotiate redirect-bound codes --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 91 +++---- src/langbot/pkg/api/http/service/space.py | 10 +- src/langbot/pkg/api/http/service/user.py | 5 +- src/langbot/pkg/cloud/directory_projection.py | 85 ++++++- .../integration/api/test_user_space_oauth.py | 233 ++++++++++++------ .../api/service/test_space_service.py | 11 +- .../cloud/test_directory_projection.py | 84 ++++++- web/src/app/auth/space/callback/page.tsx | 18 +- web/src/app/infra/http/BackendClient.ts | 15 +- web/src/app/login/page.tsx | 8 +- .../unit/cloud-new-account-entry.test.mjs | 12 +- 11 files changed, 407 insertions(+), 165 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index af37172db..03c84bf47 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -9,7 +9,6 @@ from .. import group from .....entity.errors import account as account_errors from ...context import RequestContext from .....cloud.launch import SpaceLaunchError -from .....workspace.errors import WorkspaceNotFoundError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError @@ -144,13 +143,6 @@ class UserRouterGroup(group.RouterGroup): try: redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') - cloud_entry = quart.request.args.get('cloud_entry') == '1' - if ( - cloud_entry - and not launch_workspace_uuid - and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' - ): - return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()}) if launch_workspace_uuid: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): return self.fail(1, 'Space launch requires Cloud mode') @@ -194,6 +186,9 @@ class UserRouterGroup(group.RouterGroup): json_data = await quart.request.json code = json_data.get('code') state = json_data.get('state') + redirect_uri = json_data.get('redirect_uri') or ( + quart.request.url_root.rstrip('/') + '/auth/space/callback' + ) launch_assertion = json_data.get('launch_assertion') workspace_uuid = json_data.get('workspace_uuid') @@ -207,8 +202,11 @@ class UserRouterGroup(group.RouterGroup): return self.fail(1, 'Missing authorization code') if not state: return self.fail(1, 'Missing state parameter') + if not str(code).startswith('v4_'): + return self.fail(1, 'Unsupported Space OAuth code contract') try: + redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False) consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') # Exchange code for tokens launch_workspace_uuid = consumed_state.launch_workspace_uuid @@ -226,24 +224,36 @@ class UserRouterGroup(group.RouterGroup): code, workspace_uuids, workspace_created_ats, + redirect_uri=redirect_uri, ) access_token = token_data.get('access_token') refresh_token = token_data.get('refresh_token') expires_in = token_data.get('expires_in', 0) + cloud_workspace_uuid = token_data.get('cloud_workspace_uuid') if not access_token: return self.fail(1, 'Failed to get access token from Space') - # Authenticate and create/update local user + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid: + return self.fail(1, 'Space OAuth Workspace binding mismatch') + target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid + if cloud_mode: + if not target_workspace_uuid: + return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding') + await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,)) + + # Authenticate only after the signed, exact Workspace delta has + # established the Account and membership runtime shadow rows. jwt_token, user_obj = await self.ap.user_service.authenticate_space_user( access_token, refresh_token, expires_in ) - if launch_workspace_uuid: + if target_workspace_uuid: try: access = await self.ap.workspace_collaboration_service.resolve_account_workspace( user_obj.uuid, - launch_workspace_uuid, + target_workspace_uuid, ) except Exception: self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') @@ -375,12 +385,17 @@ class UserRouterGroup(group.RouterGroup): json_data = await quart.request.json code = json_data.get('code') state = json_data.get('state') + redirect_uri = json_data.get('redirect_uri') or ( + quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind' + ) if not code: return self.http_status(400, -1, 'Missing authorization code') if not state: return self.http_status(400, -1, 'Missing state parameter') + if not str(code).startswith('v4_'): + return self.http_status(400, -1, 'Unsupported Space OAuth code contract') try: user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind') @@ -393,7 +408,10 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'Only local accounts can bind to Space') try: - updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code) + redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True) + updated_user = await self.ap.user_service.bind_space_account( + user_obj.user, code, redirect_uri=redirect_uri + ) jwt_token = await self.ap.user_service.generate_jwt_token(updated_user) return self.success( data={ @@ -436,49 +454,18 @@ class UserRouterGroup(group.RouterGroup): } ) - account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) projection_service = self.ap.directory_projection_service - access = None - # A first Cloud launch creates the personal Workspace immediately - # before redirecting here. Pull a bounded number of signed event - # pages until both the Account and its target Workspace membership - # are visible instead of rejecting during the background-sync window. - for attempt in range(4): - if account is not None: - self.ap.user_service._require_active_account(account) - try: - access = await self.ap.workspace_collaboration_service.resolve_account_workspace( - account.uuid, - launch['workspace_uuid'], - ) - break - except WorkspaceNotFoundError: - if projection_service is None: - raise - elif projection_service is None: - break - - if attempt == 3: - break - await projection_service.sync_once() - account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) - - if access is None and projection_service is not None: - # The target event may be deeper than the bounded incremental - # page budget. One authoritative signed snapshot catches this - # process up without turning the callback into unbounded polling. - await projection_service.refresh_snapshot() - account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) - if account is not None: - self.ap.user_service._require_active_account(account) - access = await self.ap.workspace_collaboration_service.resolve_account_workspace( - account.uuid, - launch['workspace_uuid'], - ) + if projection_service is None: + raise SpaceLaunchError('Cloud directory projection is unavailable') + await projection_service.reconcile_workspaces((launch['workspace_uuid'],)) + account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) if account is None: raise SpaceLaunchError('Launch Account is not projected into Core') - if access is None: # pragma: no cover - bounded loop resolves or raises. - raise SpaceLaunchError('Launch Workspace is not projected into Core') + self.ap.user_service._require_active_account(account) + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + account.uuid, + launch['workspace_uuid'], + ) token = await self.ap.user_service.generate_jwt_token(account) return self.success( data={ diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index 174cc965c..101eafdbd 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -119,21 +119,18 @@ class SpaceService: space_config = self._get_space_config() authorize_url = space_config['oauth_authorize_url'] - params = {'redirect_uri': redirect_uri} + params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'} if state: params['state'] = state return f'{authorize_url}?{urlencode(params)}' - def get_cloud_entry_url(self) -> str: - """Return the Space-owned Cloud selector for a Cloud Account login.""" - - return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta' - async def exchange_oauth_code( self, code: str, workspace_uuids: list[str] | None = None, workspace_created_ats: dict[str, int] | None = None, + *, + redirect_uri: str = '', ) -> typing.Dict: """Exchange OAuth authorization code for tokens""" from langbot.pkg.utils import constants @@ -146,6 +143,7 @@ class SpaceService: f'{space_url}/api/v1/accounts/oauth/token', json={ 'code': code, + 'redirect_uri': redirect_uri, 'instance_id': constants.instance_id, # Sending an explicit empty list tells new Space servers not to # synthesize a legacy instance-derived Workspace binding. diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 5e3ca2082..7fadccf6f 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -774,7 +774,7 @@ class UserService: f'email:{normalized_email}', ) - async def bind_space_account(self, user_email: str, code: str) -> user.User: + async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User: """Bind Space account to existing local account""" local_account = await self.get_user_by_email(user_email) if local_account is None: @@ -794,12 +794,13 @@ class UserService: code, [binding.workspace_uuid], {binding.workspace_uuid: created_ts}, + redirect_uri=redirect_uri, ) else: # Compatibility for early/bootstrap call sites that have not wired # WorkspaceService yet; old Space servers still derive the legacy # Workspace identity from instance_id when the field is omitted. - token_data = await self.ap.space_service.exchange_oauth_code(code) + token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri) access_token = token_data.get('access_token') refresh_token = token_data.get('refresh_token') expires_in = token_data.get('expires_in', 0) diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py index c5fd2147d..35a46d960 100644 --- a/src/langbot/pkg/cloud/directory_projection.py +++ b/src/langbot/pkg/cloud/directory_projection.py @@ -173,6 +173,77 @@ class DirectoryProjectionService: async with self._sync_lock: await self._sync_once() + async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None: + """Synchronously project an exact Workspace set without moving the event cursor.""" + + requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()})) + if not requested: + raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace') + if len(requested) > self.event_limit: + raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit') + async with self._sync_lock: + delta = await self.provider.fetch_workspaces(self.instance_uuid, requested) + await self._apply_targeted_delta(delta, requested) + + async def _apply_targeted_delta( + self, + delta: DirectoryDelta, + requested_workspace_uuids: tuple[str, ...], + ) -> None: + if not isinstance(delta, DirectoryDelta): + raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta') + workspace_count, membership_count = self._validate_batch_capacity( + delta.workspaces, + full_snapshot=False, + ) + delta = DirectoryDelta.model_validate(delta.model_dump()) + if delta.instance_uuid != self.instance_uuid: + raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance') + requested = set(requested_workspace_uuids) + if set(delta.requested_workspace_uuids) != requested: + raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces') + if {workspace.uuid for workspace in delta.workspaces} != requested: + raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace') + + directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None) + if not callable(directory_uow): + raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable') + + async with directory_uow(self.instance_uuid) as uow: + session = uow.session + state = await session.scalar( + sqlalchemy.select(DirectoryProjectionState) + .where(DirectoryProjectionState.instance_uuid == self.instance_uuid) + .with_for_update() + ) + if state is None: + raise DirectoryProjectionUnavailableError('Directory projection is not initialized') + snapshot = DirectorySnapshot( + instance_uuid=self.instance_uuid, + cursor=state.cursor, + generated_at=delta.generated_at, + workspaces=delta.workspaces, + ) + accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True) + await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid) + active_workspace_count = await self._enforce_active_workspace_capacity(session) + await session.flush() + + await self._update_entitlement_workspace_activity( + snapshot.workspaces, + requested_workspace_uuids=requested, + ) + self._publish_runtime_execution_projection( + snapshot.workspaces, + affected_workspace_uuids=requested, + ) + self._request_model_catalog_sync() + self._record_batch_cardinality( + active_workspaces=active_workspace_count, + workspaces=workspace_count, + memberships=membership_count, + ) + async def _sync_once(self) -> None: cursor = self._consumer_cursor if cursor is None: @@ -723,7 +794,13 @@ class DirectoryProjectionService: for row in inbox_rows: row.applied_at = now - async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]: + async def _apply_accounts( + self, + session: Any, + snapshot: DirectorySnapshot, + *, + preserve_existing: bool = False, + ) -> dict[str, User]: selected: dict[str, DirectoryMember] = {} emails: dict[str, str] = {} for workspace in snapshot.workspaces: @@ -788,6 +865,12 @@ class DirectoryProjectionService: continue if account.source != AccountSource.CLOUD_PROJECTION.value: raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account') + if preserve_existing: + # A targeted Workspace fetch has no independently monotonic + # Account revision. It may create a missing runtime shadow, but + # ordered event/snapshot projection remains the only updater of + # existing Account identity and status fields. + continue if account.projection_revision > snapshot.cursor: raise DirectoryProjectionUnavailableError('Directory account revision rolled back') projected_account = self._account_projection(member) diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 34c09451b..af2bab140 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -11,7 +11,6 @@ import pytest import quart from langbot.pkg.api.http.controller.groups.user import UserRouterGroup -from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.integration @@ -71,7 +70,6 @@ async def space_oauth_api(): application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' ) - application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta') application.space_service.exchange_oauth_code = AsyncMock( return_value={ 'access_token': 'space-access-token', @@ -129,7 +127,7 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau @pytest.mark.asyncio -async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api): +async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api): application, client = space_oauth_api application.deployment.mode = 'cloud' @@ -143,9 +141,9 @@ async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oau ) assert response.status_code == 200 - assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta') - application.space_service.get_cloud_entry_url.assert_called_once_with() - application.user_service.issue_space_oauth_state.assert_not_awaited() + authorize_url = (await response.get_json())['data']['authorize_url'] + assert authorize_url.startswith('https://space.example/authorize?state=') + application.user_service.issue_space_oauth_state.assert_awaited_once_with('login') @pytest.mark.asyncio @@ -272,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api): async def test_login_callback_requires_and_consumes_server_state(space_oauth_api): application, client = space_oauth_api - missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'}) + missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'}) response = await client.post( '/api/v1/user/space/callback', - json={'code': 'oauth-code', 'state': 'opaque-login-state'}, + json={ + 'code': 'v4_oauth-code', + 'state': 'opaque-login-state', + 'redirect_uri': 'https://oss.example/auth/space/callback', + }, ) assert (await missing.get_json())['code'] == 1 @@ -283,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api assert (await response.get_json())['data']['token'] == 'space-login-token' application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login') application.space_service.exchange_oauth_code.assert_awaited_once_with( - 'oauth-code', + 'v4_oauth-code', [WORKSPACE_UUID], {WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())}, + redirect_uri='https://oss.example/auth/space/callback', ) +@pytest.mark.asyncio +async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api): + application, client = space_oauth_api + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'}, + ) + + payload = await response.get_json() + assert response.status_code == 200 + assert payload['code'] == 1 + assert 'code contract' in payload['msg'] + application.space_service.exchange_oauth_code.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + calls: list[str] = [] + application.directory_projection_service = SimpleNamespace( + reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile')) + ) + application.space_service.exchange_oauth_code.return_value = { + 'access_token': 'space-access-token', + 'refresh_token': 'space-refresh-token', + 'expires_in': 3600, + 'cloud_workspace_uuid': WORKSPACE_UUID, + } + + authenticated_account = application.user_service.authenticate_space_user.return_value[1] + + async def authenticate(*_args): + calls.append('authenticate') + return 'space-login-token', authenticated_account + + application.user_service.authenticate_space_user.side_effect = authenticate + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + assert calls == ['reconcile', 'authenticate'] + application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,)) + + +@pytest.mark.asyncio +async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, + ) + + payload = await response.get_json() + assert response.status_code == 200 + assert payload['code'] == 1 + assert 'Cloud Workspace binding' in payload['msg'] + application.directory_projection_service.reconcile_workspaces.assert_not_awaited() + application.user_service.authenticate_space_user.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace( + launch_workspace_uuid=WORKSPACE_UUID + ) + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, + ) + + payload = await response.get_json() + assert response.status_code == 200 + assert payload['code'] == 1 + assert 'Workspace binding' in payload['msg'] + application.directory_projection_service.reconcile_workspaces.assert_not_awaited() + application.user_service.authenticate_space_user.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace( + launch_workspace_uuid=WORKSPACE_UUID + ) + application.space_service.exchange_oauth_code.return_value = { + 'access_token': 'space-access-token', + 'refresh_token': 'space-refresh-token', + 'expires_in': 3600, + 'cloud_workspace_uuid': 'workspace-from-another-flow', + } + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, + ) + + payload = await response.get_json() + assert response.status_code == 200 + assert payload['code'] == 1 + assert 'Workspace binding' in payload['msg'] + application.directory_projection_service.reconcile_workspaces.assert_not_awaited() + application.user_service.authenticate_space_user.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api): + application, client = space_oauth_api + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, + ) + + assert response.status_code == 200 + application.directory_projection_service.reconcile_workspaces.assert_not_awaited() + + @pytest.mark.asyncio async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api): application, client = space_oauth_api @@ -299,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut response = await client.post( '/api/v1/user/space/callback', - json={'code': 'oauth-code', 'state': 'opaque-login-state'}, + json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, ) assert response.status_code == 200 @@ -398,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_ rejected = await client.post( '/api/v1/user/bind-space', - json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'}, + json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'}, ) response = await client.post( '/api/v1/user/bind-space', - json={'code': 'oauth-code', 'state': 'opaque-bind-state'}, + json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'}, ) assert rejected.status_code == 401 assert response.status_code == 200 assert (await response.get_json())['data']['token'] == 'rotated-account-token' application.user_service.verify_jwt_token.assert_not_awaited() - application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code') + application.user_service.bind_space_account.assert_awaited_once_with( + 'owner@example.com', + 'v4_oauth-code', + redirect_uri='http://localhost/auth/space/callback?mode=bind', + ) @pytest.mark.asyncio @@ -417,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space application, client = space_oauth_api application.user_service.consume_space_oauth_state.reset_mock() application.space_service.exchange_oauth_code.reset_mock() + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) response = await client.post( '/api/v1/user/space/callback', @@ -440,7 +581,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space @pytest.mark.asyncio -async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api): +async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api): application, client = space_oauth_api projected_account = SimpleNamespace( uuid='account-a', @@ -448,8 +589,8 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting account_type='space', status='active', ) - application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account]) - application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) + application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account) + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) response = await client.post( '/api/v1/user/space/callback', @@ -461,61 +602,5 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting assert response.status_code == 200 assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID - assert application.directory_projection_service.sync_once.await_count == 2 - assert application.user_service.get_user_by_uuid.await_count == 3 - - -@pytest.mark.asyncio -async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api): - application, client = space_oauth_api - projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value - application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( - side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access] - ) - application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) - - response = await client.post( - '/api/v1/user/space/callback', - json={ - 'workspace_uuid': WORKSPACE_UUID, - 'launch_assertion': 'signed-launch-token', - }, - ) - - assert response.status_code == 200 - assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID - application.directory_projection_service.sync_once.assert_awaited_once_with() - assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2 - - -@pytest.mark.asyncio -async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api): - application, client = space_oauth_api - projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value - application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( - side_effect=[ - WorkspaceNotFoundError('Workspace not found'), - WorkspaceNotFoundError('Workspace not found'), - WorkspaceNotFoundError('Workspace not found'), - WorkspaceNotFoundError('Workspace not found'), - projected_access, - ] - ) - application.directory_projection_service = SimpleNamespace( - sync_once=AsyncMock(), - refresh_snapshot=AsyncMock(), - ) - - response = await client.post( - '/api/v1/user/space/callback', - json={ - 'workspace_uuid': WORKSPACE_UUID, - 'launch_assertion': 'signed-launch-token', - }, - ) - - assert response.status_code == 200 - assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID - assert application.directory_projection_service.sync_once.await_count == 3 - application.directory_projection_service.refresh_snapshot.assert_awaited_once_with() - assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5 + application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,)) + application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a') diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index a77dd14a1..731244561 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl: result = service.get_oauth_authorize_url('http://localhost/callback') # Verify - assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback'] + query = parse_qs(urlsplit(result).query) + assert query['redirect_uri'] == ['http://localhost/callback'] + assert query['code_contract'] == ['redirect-v1'] assert 'https://space.langbot.app/auth/authorize' in result def test_get_oauth_authorize_url_with_state(self): @@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode: 'auth_code', ['workspace-1'], {'workspace-1': 1_700_000_000}, + redirect_uri='https://oss.example/auth/space/callback', ) # Verify assert result['access_token'] == 'new_access_token' assert mock_session_obj.post.call_args.kwargs['json'] == { 'code': 'auth_code', + 'redirect_uri': 'https://oss.example/auth/space/callback', 'instance_id': constants.instance_id, 'workspace_uuids': ['workspace-1'], 'workspace_created_ats': {'workspace-1': 1_700_000_000}, @@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection: if response_shape == 'models-envelope': data = {'models': models} elif response_shape == 'availability-wrapper': - data = [ - {'model': model, 'latency_ms': index + 10, 'http_code': 200} - for index, model in enumerate(models) - ] + data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)] else: data = models payload = {'code': 0, 'data': data} diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 1320a9abc..756baa7be 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -4,7 +4,7 @@ import asyncio import datetime import logging from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest import sqlalchemy @@ -215,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti request_sync.assert_called_once_with() +async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context): + application, session_factory = projection_context + provider = _Provider( + [_snapshot(7, workspaces=[])], + deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])], + ) + service = DirectoryProjectionService(application, provider, INSTANCE_UUID) + await service.initialize() + + await service.reconcile_workspaces((WORKSPACE_UUID,)) + + async with session_factory() as session: + account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID)) + workspace = await session.get(Workspace, WORKSPACE_UUID) + membership = await session.scalar( + sqlalchemy.select(WorkspaceMembership).where( + WorkspaceMembership.workspace_uuid == WORKSPACE_UUID, + WorkspaceMembership.account_uuid == ACCOUNT_UUID, + ) + ) + state = await session.get(DirectoryProjectionState, INSTANCE_UUID) + assert account is not None + assert workspace is not None and workspace.name == 'JIT Workspace' + assert membership is not None and membership.status == 'active' + assert state is not None and state.cursor == 7 + assert provider.delta_calls == 1 + assert provider.after_cursors == [] + + +async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context): + application, session_factory = projection_context + targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy( + update={ + 'members': [ + _member(revision=8).model_copy(update={'display_name': 'Changed Account Name'}) + ] + } + ) + provider = _Provider( + [_snapshot(7)], + deltas=[_delta(workspaces=[targeted_workspace])], + ) + service = DirectoryProjectionService(application, provider, INSTANCE_UUID) + await service.initialize() + + await service.reconcile_workspaces((WORKSPACE_UUID,)) + + async with session_factory() as session: + account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID)) + workspace = await session.get(Workspace, WORKSPACE_UUID) + state = await session.get(DirectoryProjectionState, INSTANCE_UUID) + assert account is not None and account.user == 'Workspace Owner' + assert account.projection_revision == 7 + assert workspace is not None and workspace.name == 'Renamed Workspace' + assert state is not None and state.cursor == 7 + + +async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context): + application, _session_factory = projection_context + provider = _Provider( + [_snapshot(7, workspaces=[])], + deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])], + ) + service = DirectoryProjectionService(application, provider, INSTANCE_UUID) + await service.initialize() + service._reconcile_entitlement_snapshot_set = AsyncMock() + service._update_entitlement_workspace_activity = AsyncMock() + service._publish_runtime_execution_projection = Mock() + + await service.reconcile_workspaces((WORKSPACE_UUID,)) + + service._reconcile_entitlement_snapshot_set.assert_not_awaited() + service._update_entitlement_workspace_activity.assert_awaited_once() + assert service._update_entitlement_workspace_activity.await_args.kwargs == { + 'requested_workspace_uuids': {WORKSPACE_UUID}, + } + service._publish_runtime_execution_projection.assert_called_once() + assert service._publish_runtime_execution_projection.call_args.kwargs == { + 'affected_workspace_uuids': {WORKSPACE_UUID}, + } + + async def test_initial_snapshot_projects_core_owned_rows(projection_context): application, session_factory = projection_context reconcile_execution_projection = Mock() diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index c2c142589..ea41ded6c 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map< function getOrCreateSpaceOAuthLoginPromise( authCode: string, state: string, + redirectUri: string, workspaceUuid?: string, launchAssertion?: string, ): Promise { - const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`; + const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`; const pendingRequest = pendingSpaceOAuthLogins.get(requestKey); if (pendingRequest) { return pendingRequest; } const requestPromise = httpClient - .exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion) + .exchangeSpaceOAuthCode( + authCode, + state, + redirectUri, + workspaceUuid, + launchAssertion, + ) .finally(() => { pendingSpaceOAuthLogins.delete(requestKey); }); @@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() { const response = await getOrCreateSpaceOAuthLoginPromise( authCode, state, + `${window.location.origin}/auth/space/callback`, workspaceUuid, launchAssertion, ); @@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() { async (authCode: string, state: string) => { setIsProcessing(true); try { - const response = await httpClient.bindSpaceAccount(authCode, state); + const response = await httpClient.bindSpaceAccount( + authCode, + state, + `${window.location.origin}/auth/space/callback?mode=bind`, + ); if (!isMountedRef.current) { return; } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 41aac21cb..c9d742c4c 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1365,6 +1365,7 @@ export class BackendClient extends BaseHttpClient { public async bindSpaceAccount( code: string, state: string, + redirectUri: string, ): Promise<{ token: string; user: string; @@ -1372,7 +1373,7 @@ export class BackendClient extends BaseHttpClient { }> { const response = await this.instance.post( '/api/v1/user/bind-space', - { code, state }, + { code, state, redirect_uri: redirectUri }, { skipWorkspace: true } as RequestConfig, ); if (response.data.code !== 0) { @@ -1385,18 +1386,12 @@ export class BackendClient extends BaseHttpClient { } // ============ Space OAuth API (Redirect Flow) ============ - public getSpaceAuthorizeUrl( - redirectUri: string, - options?: { cloudEntry?: boolean }, - ): Promise<{ + public getSpaceAuthorizeUrl(redirectUri: string): Promise<{ authorize_url: string; }> { return this.get( '/api/v1/user/space/authorize-url', - { - redirect_uri: redirectUri, - ...(options?.cloudEntry ? { cloud_entry: '1' } : {}), - }, + { redirect_uri: redirectUri }, { skipWorkspace: true }, ); } @@ -1414,6 +1409,7 @@ export class BackendClient extends BaseHttpClient { public async exchangeSpaceOAuthCode( code: string, state: string, + redirectUri: string, workspaceUuid?: string, launchAssertion?: string, ): Promise<{ @@ -1428,6 +1424,7 @@ export class BackendClient extends BaseHttpClient { { code, state, + redirect_uri: redirectUri, workspace_uuid: workspaceUuid, launch_assertion: launchAssertion, }, diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 061a21988..48436017f 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -202,13 +202,7 @@ export default function Login() { try { const currentOrigin = window.location.origin; const redirectUri = `${currentOrigin}/auth/space/callback`; - const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, { - // Cloud Accounts must be launched from Space so a first visit can - // lazily create and project the personal Workspace. Invitation login - // remains on the OAuth callback path because it targets the invited - // Workspace instead. - cloudEntry: !getPendingInvitationToken(), - }); + const response = await httpClient.getSpaceAuthorizeUrl(redirectUri); window.location.href = response.authorize_url; } catch { toast.error(t('common.spaceLoginFailed')); diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs index 5420da59e..8a096be33 100644 --- a/web/tests/unit/cloud-new-account-entry.test.mjs +++ b/web/tests/unit/cloud-new-account-entry.test.mjs @@ -7,11 +7,13 @@ const source = fs.readFileSync( 'utf8', ); -test('normal Cloud login enters through the Space Workspace launcher', () => { - assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); - assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/); +test('normal Cloud login uses the standard Space OAuth callback path', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/); }); -test('invitation login remains on the OAuth callback path', () => { - assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); +test('invitation login uses the same OAuth callback before accepting the invitation', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/); + assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/); }); From d942bfe19ad4189d1b7feb0238b901a8a0b84cd7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Thu, 3 Sep 2026 23:02:40 -0600 Subject: [PATCH 13/56] fix(wecom): read media_id instead of media in send_message() (#2447) WecomMessageConverter.yiri2target() always emits {'media_id': ...} for image/voice/file parts (never 'media'), matching the correct usage already in reply_message(). send_message(), the entry point plugins use via PluginToRuntimeAction.SEND_MESSAGE, instead read content['media'], which is never set, so any image/voice/file part raises KeyError and aborts the send. Refs #1687 Signed-off-by: Amir Fathi --- src/langbot/pkg/platform/sources/wecom.py | 6 +- .../platform/test_wecom_send_message.py | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/platform/test_wecom_send_message.py diff --git a/src/langbot/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py index 93aaf1f92..d555599af 100644 --- a/src/langbot/pkg/platform/sources/wecom.py +++ b/src/langbot/pkg/platform/sources/wecom.py @@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if content['type'] == 'text': await self.bot.send_private_msg(user_id, agent_id, content['content']) if content['type'] == 'image': - await self.bot.send_image(user_id, agent_id, content['media']) + await self.bot.send_image(user_id, agent_id, content['media_id']) if content['type'] == 'voice': - await self.bot.send_voice(user_id, agent_id, content['media']) + await self.bot.send_voice(user_id, agent_id, content['media_id']) if content['type'] == 'file': - await self.bot.send_file(user_id, agent_id, content['media']) + await self.bot.send_file(user_id, agent_id, content['media_id']) def register_listener( self, diff --git a/tests/unit_tests/platform/test_wecom_send_message.py b/tests/unit_tests/platform/test_wecom_send_message.py new file mode 100644 index 000000000..20c9e6976 --- /dev/null +++ b/tests/unit_tests/platform/test_wecom_send_message.py @@ -0,0 +1,59 @@ +"""Tests for WecomAdapter.send_message content-key handling.""" + +import pytest + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.wecom import WecomAdapter + + +class StubWecomClient: + def __init__(self): + self.calls = [] + + async def get_media_id(self, msg): + return 'MEDIA_ID_123' + + async def send_private_msg(self, user_id, agent_id, text): + self.calls.append(('text', user_id, agent_id, text)) + + async def send_image(self, user_id, agent_id, media_id): + self.calls.append(('image', user_id, agent_id, media_id)) + + async def send_voice(self, user_id, agent_id, media_id): + self.calls.append(('voice', user_id, agent_id, media_id)) + + async def send_file(self, user_id, agent_id, media_id): + self.calls.append(('file', user_id, agent_id, media_id)) + + +def _make_adapter(): + adapter = WecomAdapter.model_construct(bot=StubWecomClient()) + return adapter + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('part', 'expected_type'), + [ + (platform_message.Image(url='https://example.com/x.jpg'), 'image'), + (platform_message.Voice(url='https://example.com/x.amr'), 'voice'), + (platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'), + ], +) +async def test_send_message_dispatches_media_by_id(part, expected_type): + adapter = _make_adapter() + chain = platform_message.MessageChain([part]) + + await adapter.send_message('person', 'USER1|1000001', chain) + + assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')] + + +@pytest.mark.asyncio +async def test_send_message_text_still_works(): + adapter = _make_adapter() + chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'USER1|1000001', chain) + + assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')] From cb45807b12ee002d1a7d6fe4c157f375cf264b70 Mon Sep 17 00:00:00 2001 From: mintya Date: Fri, 4 Sep 2026 13:05:36 +0800 Subject: [PATCH 14/56] fix(qqofficial): tolerate missing optional token in adapter config (#2496) Since b55f073e the token field is optional in qqofficial.yaml ("the current adapter implementation does not use it either, so it can be safely left blank"), but the adapter constructor still reads it with config['token']. Creating a bot via QR binding (which only returns appid/secret) or with the token field left blank raises KeyError and the API returns 500. Read it with config.get('token', '') instead. The value is never used by QQOfficialClient beyond being stored, so an empty string default is safe. --- src/langbot/pkg/platform/sources/qqofficial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index f65a9683e..f012b07f9 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter bot = QQOfficialClient( app_id=config['appid'], secret=config['secret'], - token=config['token'], + token=config.get('token', ''), logger=logger, unified_mode=enable_webhook, ) From de3c0b00ad7ce7d6a37c2e550ccde6a278cd0a2f Mon Sep 17 00:00:00 2001 From: mintya Date: Fri, 4 Sep 2026 13:06:12 +0800 Subject: [PATCH 15/56] fix(bot): roll back inserted bot row when adapter fails to load (#2497) create_bot inserts the Bot row first and only then instantiates the adapter via platform_mgr.load_bot. When the adapter constructor raises (e.g. KeyError on a missing credential key), the insert is already committed and nothing removes the row: the HTTP layer returns 500 but a permanently disabled orphan bot stays in the DB. Callers never receive the bot uuid, so they cannot compensate by deleting it, and load_bots_from_db skips enable=False bots, so the orphan is never loaded or surfaced anywhere. Wrap load_bot in try/except and delete the inserted row before re-raising. Add a regression test asserting the DELETE is issued when the adapter constructor fails. --- src/langbot/pkg/api/http/service/bot.py | 11 +++- .../api/service/test_bot_service.py | 53 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 42bb35338..316ac02a7 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -137,7 +137,16 @@ class BotService: bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) - await self.ap.platform_mgr.load_bot(context, bot) + try: + await self.ap.platform_mgr.load_bot(context, bot) + except Exception: + # The bot row was already inserted above; without this rollback a + # failing adapter constructor (e.g. a missing optional credential + # key) would leave a permanently disabled orphan bot in the DB. + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid']) + ) + raise return bot_data['uuid'] diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 55869fc0a..fd809165e 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -12,6 +12,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch from types import SimpleNamespace import json +import sqlalchemy import uuid from langbot.pkg.api.http.service.bot import BotService @@ -449,10 +450,58 @@ class TestBotServiceCreateBot: insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0] insert_values = insert_statement.compile().params assert insert_values['workspace_uuid'] == WORKSPACE_UUID - assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid' - assert insert_values['use_pipeline_name'] == 'Default Pipeline' assert bot_uuid is not None # Verify UUID was returned + async def test_create_bot_rolls_back_insert_when_load_bot_fails(self): + """Deletes the inserted row when the adapter fails to load. + + Regression: a failing adapter constructor (e.g. KeyError on a missing + optional credential key) used to leave a permanently disabled orphan + bot in the DB — the insert was already committed and the HTTP layer + surfaced a 500 without any cleanup. + """ + # Setup + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.instance_config = SimpleNamespace() + ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}} + ap.platform_mgr = SimpleNamespace() + ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token')) + + pipeline_result = Mock() + pipeline_result.first = Mock(return_value=None) + bot_result = Mock() + bot_result.first = Mock(return_value=_create_mock_bot()) + + executed_statements = [] + + async def mock_execute(query): + executed_statements.append(query) + if len(executed_statements) <= 2: + return pipeline_result # 1: limitation bots query, 2: pipeline query + if len(executed_statements) == 3: + return Mock() # insert + return bot_result # get_bot after insert + + ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'}) + + service = BotService(ap) + + # Execute & Verify: the adapter error propagates + with pytest.raises(KeyError, match='token'): + await service.create_bot( + WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}} + ) + + # And the inserted row is rolled back via a DELETE on the new uuid + # (no limitation query runs because max_bots=-1) + assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete + delete_statement = executed_statements[-1] + assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete) + compiled = delete_statement.compile() + assert compiled.params['uuid_1'] is not None + class TestBotServiceUpdateBot: """Tests for update_bot method.""" From a63808caa6f10bc11f711373cabc11ffb6519b00 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 4 Sep 2026 21:47:31 +0800 Subject: [PATCH 16/56] chore(release): prepare LangBot 4.10.10 (#2507) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fddfe5245..7f886df12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.9" +version = "4.10.10" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -70,7 +70,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.5.6", + "langbot-plugin==0.5.7", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 4a8e9e701..24d57e3ee 100644 --- a/uv.lock +++ b/uv.lock @@ -2008,7 +2008,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.9" +version = "4.10.10" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -2129,7 +2129,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.6" }, + { name = "langbot-plugin", specifier = "==0.5.7" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2196,7 +2196,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.6" +version = "0.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2217,9 +2217,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" }, ] [[package]] From 9794df093334a2410f120d8f1b6952f8f675723f Mon Sep 17 00:00:00 2001 From: leonoxo Date: Fri, 4 Sep 2026 22:36:40 +0800 Subject: [PATCH 17/56] feat(n8n-runner): support async response handling (#2487) * feat(n8n-runner): support async response handling * fix(n8n-runner): expose response handling in form * fix(n8n-runner): preserve async response semantics --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/provider/runners/n8nsvapi.py | 14 ++- .../templates/default-pipeline-config.json | 3 +- .../templates/metadata/pipeline/ai.yaml | 19 ++++ tests/unit_tests/pipeline/test_n8nsvapi.py | 89 ++++++++++++++++++- .../dynamic-form/N8nAuthFieldVisibility.ts | 22 +++++ .../dynamic-form/N8nAuthFormComponent.tsx | 27 +----- .../unit/n8n-auth-field-visibility.test.mjs | 52 +++++++++++ 7 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 web/src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts create mode 100644 web/tests/unit/n8n-auth-field-visibility.test.mjs diff --git a/src/langbot/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py index 24ef7f59c..8addfc02e 100644 --- a/src/langbot/pkg/provider/runners/n8nsvapi.py +++ b/src/langbot/pkg/provider/runners/n8nsvapi.py @@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner): # 获取输出键名,默认为response self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response') + self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply') + if self.response_handling not in {'reply', 'ignore'}: + raise ValueError(f'Invalid n8n response-handling: {self.response_handling}') # 获取认证类型,默认为none self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none') @@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner): async with session.post( self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout ) as response: - if response.status != 200: + if self.response_handling == 'ignore': + status_ok = 200 <= response.status < 300 + else: + status_ok = response.status == 200 + if not status_ok: error_text = ( await httpclient.read_limited( response, @@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner): self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') raise Exception(f'n8n webhook call failed: {response.status}, {error_text}') + if self.response_handling == 'ignore': + response.release() + self.ap.logger.debug('n8n async webhook accepted; response body ignored') + return + async for chunk in self._process_response(response): if is_stream: yield chunk diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 78e2ec958..74009804f 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -80,7 +80,8 @@ "header-name": "", "header-value": "", "timeout": 120, - "output-key": "response" + "output-key": "response", + "response-handling": "reply" }, "langflow-api": { "base-url": "http://localhost:7860", diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index ccf009941..e16a33f49 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -475,6 +475,25 @@ stages: type: string required: false default: 'response' + - name: response-handling + label: + en_US: Webhook Response Handling + zh_Hans: Webhook 响应处理方式 + description: + en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately. + zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。 + type: select + required: false + default: 'reply' + options: + - name: reply + label: + en_US: Forward as chat reply + zh_Hans: 转发为聊天回复 + - name: ignore + label: + en_US: Ignore response body (asynchronous workflow) + zh_Hans: 忽略响应正文(异步工作流) - name: coze-api label: en_US: coze API diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py index 54266aec4..f7ed7edc6 100644 --- a/tests/unit_tests/pipeline/test_n8nsvapi.py +++ b/tests/unit_tests/pipeline/test_n8nsvapi.py @@ -55,7 +55,7 @@ finally: # --------------------------------------------------------------------------- -def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: +def make_runner(output_key: str = 'response', response_handling: str = 'reply') -> N8nServiceAPIRunner: ap = Mock() ap.logger = Mock() pipeline_config = { @@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: 'n8n-service-api': { 'webhook-url': 'http://test-n8n/webhook', 'output-key': output_key, + 'response-handling': response_handling, 'auth-type': 'none', } } @@ -287,6 +288,7 @@ def make_http_session_mock(response_bytes: bytes, status: int = 200): """Mock httpclient.get_session() returning a session whose post() yields response_bytes.""" mock_response = make_mock_response([response_bytes], status=status) mock_response.status = status + mock_response.headers = {} mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) @@ -314,6 +316,91 @@ async def test_call_webhook_nonstream_adapter_plain_json(): assert results[0].content == 'result text' +@pytest.mark.asyncio +@pytest.mark.parametrize('status', [200, 201, 202, 204]) +@pytest.mark.parametrize( + 'response_body', + [ + b'{"message":"Workflow was started"}', + b'{"response":"must not be forwarded"}', + b'plain acknowledgement', + ], +) +async def test_call_webhook_ignore_response_body(response_body: bytes, status: int): + """Ignore mode accepts any HTTP 2xx response without emitting chat output.""" + runner = make_runner(response_handling='ignore') + query = make_query(is_stream=False) + http_session = make_http_session_mock(response_body, status=status) + + with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): + results = [] + async for message in runner._call_webhook(query): + results.append(message) + + assert results == [] + + +@pytest.mark.asyncio +async def test_call_webhook_ignore_releases_without_reading_response_body(): + """Ignore mode returns after the success status without waiting for the body.""" + runner = make_runner(response_handling='ignore') + query = make_query(is_stream=False) + mock_response = make_mock_response([], status=202) + mock_response.headers = {} + mock_response.release = Mock() + + async def fail_if_read(_size): + raise AssertionError('ignore mode must not read the response body') + yield b'' + + mock_response.content.iter_chunked = fail_if_read + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_response) + mock_cm.__aexit__ = AsyncMock(return_value=False) + mock_session = Mock() + mock_session.post = Mock(return_value=mock_cm) + + with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=mock_session): + results = [message async for message in runner._call_webhook(query)] + + assert results == [] + mock_response.release.assert_called_once_with() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status', [201, 202, 204]) +async def test_call_webhook_reply_mode_preserves_http_200_contract(status: int): + """Reply mode remains backward compatible and rejects non-200 statuses.""" + runner = make_runner(response_handling='reply') + query = make_query(is_stream=False) + http_session = make_http_session_mock(b'', status=status) + + with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): + with pytest.raises(N8nAPIError, match=f'n8n webhook call failed: {status}'): + async for _ in runner._call_webhook(query): + pass + + +@pytest.mark.asyncio +async def test_call_webhook_ignore_mode_preserves_http_error(): + """Ignore mode must not swallow a failed n8n webhook response.""" + runner = make_runner(response_handling='ignore') + query = make_query(is_stream=False) + http_session = make_http_session_mock(b'{"error":"unavailable"}', status=500) + + with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): + with pytest.raises(N8nAPIError, match='n8n webhook call exception'): + async for _ in runner._call_webhook(query): + pass + + +@pytest.mark.asyncio +async def test_invalid_response_handling_is_rejected(): + """Configuration errors should fail fast instead of silently changing reply behavior.""" + with pytest.raises(ValueError, match='Invalid n8n response-handling'): + make_runner(response_handling='unexpected') + + @pytest.mark.asyncio async def test_call_webhook_stream_adapter_stream_format(): """Stream adapter + stream format → MessageChunks, last is_final.""" diff --git a/web/src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts b/web/src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts new file mode 100644 index 000000000..4bae431a5 --- /dev/null +++ b/web/src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts @@ -0,0 +1,22 @@ +const COMMON_N8N_CONFIG_FIELDS = new Set([ + 'webhook-url', + 'auth-type', + 'timeout', + 'output-key', + 'response-handling', +]); + +export function shouldShowN8nConfigField( + fieldName: string, + authType: string, +): boolean { + if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) { + return true; + } + + return ( + (authType === 'basic' && fieldName.startsWith('basic-')) || + (authType === 'jwt' && fieldName.startsWith('jwt-')) || + (authType === 'header' && fieldName.startsWith('header-')) + ); +} diff --git a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx index 6d80b659b..94770b33e 100644 --- a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx @@ -13,6 +13,7 @@ import { import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; +import { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility'; import { extractI18nObject } from '@/i18n/I18nProvider'; /** @@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({ }, [form, itemConfigList]); // 根据认证类型过滤表单项 - const filteredConfigList = itemConfigList.filter((config) => { - // 始终显示webhook-url、auth-type、timeout和output-key - if ( - ['webhook-url', 'auth-type', 'timeout', 'output-key'].includes( - config.name, - ) - ) { - return true; - } - - // 根据认证类型显示相应的表单项 - if (authType === 'basic' && config.name.startsWith('basic-')) { - return true; - } - if (authType === 'jwt' && config.name.startsWith('jwt-')) { - return true; - } - if (authType === 'header' && config.name.startsWith('header-')) { - return true; - } - - return false; - }); + const filteredConfigList = itemConfigList.filter((config) => + shouldShowN8nConfigField(config.name, authType), + ); return (
diff --git a/web/tests/unit/n8n-auth-field-visibility.test.mjs b/web/tests/unit/n8n-auth-field-visibility.test.mjs new file mode 100644 index 000000000..7a022d774 --- /dev/null +++ b/web/tests/unit/n8n-auth-field-visibility.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts', +); + +function loadVisibilityPolicy() { + const source = fs.readFileSync(sourcePath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + () => { + throw new Error('N8nAuthFieldVisibility must not have runtime imports'); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +test('shows response handling with the other common n8n fields', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + for (const field of [ + 'webhook-url', + 'auth-type', + 'timeout', + 'output-key', + 'response-handling', + ]) { + assert.equal(shouldShowN8nConfigField(field, 'none'), true, field); + } +}); + +test('shows only fields for the selected n8n authentication method', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true); + assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false); + assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true); + assert.equal(shouldShowN8nConfigField('header-name', 'header'), true); + assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false); +}); From 1cfe87186ce412ad08a79bc1ca5ff0ed42326f04 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 5 Sep 2026 00:55:18 +0800 Subject: [PATCH 18/56] docs: update published documentation links (#2509) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- AGENTS.md | 4 ++-- README.md | 2 +- README_CN.md | 2 +- README_ES.md | 2 +- README_FR.md | 2 +- README_JP.md | 2 +- README_KO.md | 2 +- README_RU.md | 2 +- README_TW.md | 2 +- README_VI.md | 2 +- docker/docker-compose.yaml | 2 +- docker/kubernetes.yaml | 2 +- docs/HTTP_BOT_ADAPTER_DESIGN.md | 4 ++-- docs/SEEKDB_INTEGRATION.md | 2 +- examples/http-bot/README.md | 2 +- examples/http-bot/README.zh.md | 2 +- examples/web-page-bot/README.md | 2 +- examples/web-page-bot/README.zh.md | 2 +- pyproject.toml | 2 +- skills/skills/langbot-deploy/SKILL.md | 2 +- src/langbot/__main__.py | 2 +- src/langbot/pkg/core/app.py | 4 ++-- src/langbot/pkg/platform/sources/http_bot.yaml | 6 +++--- src/langbot/pkg/workspace/invitation_delivery.py | 2 +- .../unit_tests/workspace/test_invitation_delivery.py | 2 +- .../components/home-sidebar/sidbarConfigList.tsx | 12 ++++++------ 26 files changed, 36 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a886d2e1a..b6c6dc401 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need ## Where to Look - Architecture map: `ARCHITECTURE.md`. -- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. -- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. +- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config. +- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime. - API-key auth: `docs/API_KEY_AUTH.md`. - Box deep-dive notes: `docs/review/box-architecture.md` and related files. - In-repo skills: `skills/` is the single source of truth for LangBot agent skills. diff --git a/README.md b/README.md index f6eeb0b22..a9585e9e6 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_CN.md b/README_CN.md index 77b448f87..35560255f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -89,7 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- diff --git a/README_ES.md b/README_ES.md index 2f7ec0ce1..836888e33 100644 --- a/README_ES.md +++ b/README_ES.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_FR.md b/README_FR.md index 66d1f9bb6..3a45877c0 100644 --- a/README_FR.md +++ b/README_FR.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_JP.md b/README_JP.md index efc6e23b2..11418c3af 100644 --- a/README_JP.md +++ b/README_JP.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_KO.md b/README_KO.md index 7e0284788..ff1349337 100644 --- a/README_KO.md +++ b/README_KO.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_RU.md b/README_RU.md index f779c0c46..dacfc589e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/README_TW.md b/README_TW.md index 9abb32976..85e7b22ab 100644 --- a/README_TW.md +++ b/README_TW.md @@ -90,7 +90,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- diff --git a/README_VI.md b/README_VI.md index ca9ef667d..98f98613b 100644 --- a/README_VI.md +++ b/README_VI.md @@ -88,7 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) +**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index c2c276ac5..cb5f1c88e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # Docker Compose configuration for LangBot -# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app +# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs version: "3" services: diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 5504e5219..7ce145610 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -1,7 +1,7 @@ # Kubernetes Deployment for LangBot # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # -# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes +# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes # # Usage: # kubectl -n langbot create secret generic langbot-plugin-runtime-control \ diff --git a/docs/HTTP_BOT_ADAPTER_DESIGN.md b/docs/HTTP_BOT_ADAPTER_DESIGN.md index 31e9a4861..d0826f2b5 100644 --- a/docs/HTTP_BOT_ADAPTER_DESIGN.md +++ b/docs/HTTP_BOT_ADAPTER_DESIGN.md @@ -218,8 +218,8 @@ metadata: spec: categories: [popular, global] help_links: - zh: https://docs.langbot.app/zh/platforms/http-bot - en: https://docs.langbot.app/en/platforms/http-bot + zh: https://langbot.app/docs/zh/platforms/http-bot + en: https://langbot.app/docs/en/platforms/http-bot config: - { name: inbound_secret, type: string, required: true, default: "" } - { name: callback_url, type: string, required: false, default: "" } diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md index a38eb9f08..4a2b55181 100644 --- a/docs/SEEKDB_INTEGRATION.md +++ b/docs/SEEKDB_INTEGRATION.md @@ -243,7 +243,7 @@ For large datasets: - SeekDB GitHub: https://github.com/oceanbase/seekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb - OceanBase Documentation: https://oceanbase.ai -- LangBot Documentation: https://docs.langbot.app +- LangBot Documentation: https://langbot.app/docs ## License diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md index b04387d78..62a1e469d 100644 --- a/examples/http-bot/README.md +++ b/examples/http-bot/README.md @@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter. They show the whole loop: signing a request, pushing a message, and receiving multi-part replies on a callback endpoint. -Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). +Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot). Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). ## Files diff --git a/examples/http-bot/README.zh.md b/examples/http-bot/README.zh.md index 1baf81272..fbd2f4394 100644 --- a/examples/http-bot/README.zh.md +++ b/examples/http-bot/README.zh.md @@ -6,7 +6,7 @@ 它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 1→M 的多段回复。 -完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 +完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。 机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 ## 文件清单 diff --git a/examples/web-page-bot/README.md b/examples/web-page-bot/README.md index e31f41ca2..d52bd9a52 100644 --- a/examples/web-page-bot/README.md +++ b/examples/web-page-bot/README.md @@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot** (`web_page_bot`) embeddable chat widget — the one you drop onto any website with a single ``, + }), + ); + await page.addInitScript((mode) => { + const w = window as any; + w.copyEvents = []; + document.addEventListener('copy', () => { + const el = document.activeElement as HTMLTextAreaElement; + w.copyEvents.push({ + tag: el.tagName, + selected: el.value?.slice(el.selectionStart, el.selectionEnd), + }); + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: + mode === 'unavailable' + ? undefined + : { + writeText: (text: string) => { + if (mode === 'success') { + w.written = text; + return Promise.resolve(); + } + if (mode === 'delayed') + return new Promise((resolve) => { + w.resolveCopy = resolve; + }); + return Promise.reject(new Error('denied')); + }, + }, + }); + if (mode === 'false') document.execCommand = () => false; + if (mode === 'throw') + document.execCommand = () => { + throw new Error('denied'); + }; + }, mode); + await page.goto('/copy-harness'); + await expect( + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }), + ).toBeVisible(); +} +const copy = (page: Page) => + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }); +const copied = (page: Page) => + page.getByRole('button', { name: 'models.codex.copied', exact: true }); + +test('Clipboard API success shows icon, toast and transient feedback', async ({ + page, +}) => { + await mount(page, 'success'); + await expect(copy(page).locator('svg.lucide-copy')).toBeVisible(); + await copy(page).click(); + await expect(copied(page).locator('svg.lucide-check')).toBeVisible(); + await expect( + page.getByText('common.copySuccess', { exact: true }), + ).toBeVisible(); + expect(await page.evaluate(() => (window as any).written)).toBe( + 'FIXTURE-1234', + ); + await expect(copy(page)).toBeVisible({ timeout: 4000 }); +}); +for (const mode of ['unavailable', 'rejected']) + test(`${mode} API performs a real selected-text copy inside modal`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([ + { tag: 'TEXTAREA', selected: 'FIXTURE-1234' }, + ]); + await expect(copied(page)).toBeFocused(); + await expect(page.locator('textarea')).toHaveCount(0); + }); +for (const mode of ['false', 'throw']) + test(`${mode} fallback reports failure and manual guidance`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect( + page.getByText('common.copyFailed', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('models.codex.copyManually', { exact: true }), + ).toBeVisible(); + await expect(copy(page)).toBeVisible(); + await expect(page.locator('textarea')).toHaveCount(0); + await expect(copy(page)).toBeFocused(); + }); +test('new code or attempt clears copied feedback', async ({ page }) => { + await mount(page, 'success'); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(copy(page)).toBeVisible(); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-3'), + ); + await expect(copy(page)).toBeVisible(); +}); +test('completion from an old attempt cannot mark the new code copied', async ({ + page, +}) => { + await mount(page, 'delayed'); + await copy(page).click(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(page.getByText('FIXTURE-5678')).toBeVisible(); + await page.evaluate(() => (window as any).resolveCopy()); + await expect(copy(page)).toBeVisible(); + await expect(copied(page)).toHaveCount(0); +}); diff --git a/web/tests/e2e/codex-subscription.spec.ts b/web/tests/e2e/codex-subscription.spec.ts new file mode 100644 index 000000000..5a67867f9 --- /dev/null +++ b/web/tests/e2e/codex-subscription.spec.ts @@ -0,0 +1,341 @@ +import { writeFileSync } from 'node:fs'; +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All OAuth, provider and model responses here are explicit UI fixtures. +// These tests never authenticate with OpenAI or use a real subscription. +async function fixture(page: Page) { + await installLangBotApiMocks(page, { authenticated: true }); + const state = { + providers: [] as Record[], + creates: 0, + starts: 0, + polls: 0, + cancels: 0, + disconnects: 0, + connected: false, + failStart: false, + pollStatus: 'pending', + interval: 1, + expiresIn: 600, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai-codex', 'openai'].map((name) => ({ + name, + label: { + en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [ + { name: 'base_url', default: 'https://api.openai.com/v1' }, + ], + }, + })), + }); + if (path.endsWith('/providers')) { + if (method === 'POST') { + state.creates++; + const provider = { + ...route.request().postDataJSON(), + uuid: `provider-${state.creates}`, + }; + state.providers.push(provider); + return ok(route, { uuid: provider.uuid }); + } + return ok(route, { providers: state.providers }); + } + if (path.endsWith('/codex/status')) + return ok(route, { + status: state.connected ? 'connected' : 'disconnected', + connected: state.connected, + expires_at: null, + }); + if (path.endsWith('/codex/device') && method === 'POST') { + state.starts++; + if (state.failStart) + return route.fulfill({ + status: 400, + json: { code: 400, msg: 'Fixture start failure' }, + }); + return ok(route, { + authorization_id: `attempt-${state.starts}`, + user_code: 'TEST-1234', + verification_uri: 'https://auth.openai.com/codex/device', + interval: state.interval, + expires_at: Date.now() / 1000 + state.expiresIn, + }); + } + if (path.endsWith('/codex/device/poll')) { + state.polls++; + expect(route.request().postDataJSON()).toEqual({ + authorization_id: `attempt-${state.starts}`, + }); + if (state.pollStatus === 'connected') state.connected = true; + return ok(route, { status: state.pollStatus, interval: state.interval }); + } + if (path.includes('/codex/device/') && method === 'DELETE') { + state.cancels++; + return ok(route, {}); + } + if (path.endsWith('/codex/auth') && method === 'DELETE') { + state.disconnects++; + state.connected = false; + return ok(route, {}); + } + if (/\/providers\/provider-\d+$/.test(path)) { + const provider = state.providers.find((p) => + path.endsWith(String(p.uuid)), + ); + if (method === 'PUT') + Object.assign(provider!, route.request().postDataJSON()); + return ok(route, { provider }); + } + if (path.includes('/models/')) return ok(route, { models: [] }); + return ok(route, {}); + }); + return state; +} + +async function openModels(page: Page) { + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); +} +async function choose(page: Page, name: string) { + await page + .getByRole('button', { name: 'Select Provider Type', exact: true }) + .click(); + await page.getByRole('button', { name: new RegExp(name) }).click(); +} + +for (const width of [1280, 390, 320]) { + test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({ + page, + }) => { + const state = await fixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await openModels(page); + await page.setViewportSize({ width, height: 900 }); + await page.locator('input[name="name"]').fill('My Codex'); + await choose(page, 'OpenAI Codex'); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect(page.locator('input[name="base_url"]')).toHaveCount(0); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.getByRole('button', { name: 'Copy code', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Copied', exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Copy Successfully', { exact: true }), + ).toBeInViewport({ ratio: 1 }); + expect(state.creates).toBe(1); + expect(state.providers[0]).toMatchObject({ + requester: 'openai-codex', + api_keys: [], + base_url: 'https://chatgpt.com/backend-api/codex', + }); + await expect( + page.getByRole('link', { name: 'Continue at OpenAI' }), + ).toHaveAttribute('href', 'https://auth.openai.com/codex/device'); + const geometry = await page.getByTestId('codex-account').evaluate((el) => { + const box = el.getBoundingClientRect(); + return { + left: box.left, + right: box.right, + width: innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(width); + expect(geometry.documentWidth).toBeLessThanOrEqual(width); + if (process.env.CODEX_EVIDENCE_DIR) { + await page.locator('[data-sonner-toast]').evaluate(async (el) => { + await Promise.all( + el + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + writeFileSync( + `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`, + JSON.stringify( + { + evidence: 'UI fixture only; not live OpenAI sign-in', + viewport: { width, height: 900 }, + geometry, + screenshot, + }, + null, + 2, + ), + ); + } + state.pollStatus = 'connected'; + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(page.getByText('My Codex', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Add Model', exact: true }), + ).toBeVisible(); + expect(state.creates).toBe(1); + expect( + await page.evaluate(() => JSON.stringify({ ...localStorage })), + ).not.toContain('attempt-'); + }); +} + +test('failed start retries reuse saved provider; cancellation refreshes list', async ({ + page, +}) => { + const state = await fixture(page); + state.failStart = true; + await openModels(page); + await page.locator('input[name="name"]').fill('Retry Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByRole('alert')).toContainText('Unable to sign in'); + state.failStart = false; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect.poll(() => state.cancels).toBe(1); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); +}); + +test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({ + page, +}) => { + const state = await fixture(page); + state.pollStatus = 'connected'; + await openModels(page); + await page.locator('input[name="name"]').fill('Managed Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + state.pollStatus = 'pending'; + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(0); + await page.getByRole('button', { name: 'Disconnect', exact: true }).click(); + expect(state.disconnects).toBe(0); + await page + .getByRole('button', { name: 'Confirm disconnect', exact: true }) + .click(); + await expect(page.getByText('Not connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(1); + expect(state.creates).toBe(1); +}); + +test('expiration permits retry without duplicate provider and closing cancels pending login', async ({ + page, +}) => { + const state = await fixture(page); + state.expiresIn = 1; + await openModels(page); + await page.locator('input[name="name"]').fill('Expired Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect( + page.getByText('Sign-in expired. Start again to get a new code.'), + ).toBeVisible(); + await expect.poll(() => state.cancels).toBe(1); + state.expiresIn = 600; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(2); + await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await page.locator('input[name="name"]').fill('Second Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + expect(state.creates).toBe(2); + expect(state.providers.map((provider) => provider.name)).toEqual([ + 'Expired Codex', + 'Second Codex', + ]); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(3); +}); + +test('model test retains the connected provider identity', async ({ page }) => { + const state = await fixture(page); + state.connected = true; + state.providers.push({ + uuid: 'provider-1', + name: 'Connected Codex', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Model', exact: true }).click(); + await page + .getByPlaceholder('Model Name', { exact: true }) + .fill('fixture-codex-model'); + const requestPromise = page.waitForRequest('**/models/llm/_/test'); + await page.getByRole('button', { name: 'Test', exact: true }).click(); + const payload = (await requestPromise).postDataJSON(); + expect(payload.provider_uuid).toBe('provider-1'); + expect(payload.provider.uuid).toBe('provider-1'); + expect(payload.provider.api_keys).toEqual([]); +}); + +test('ordinary API-key provider still saves and closes', async ({ page }) => { + const state = await fixture(page); + await openModels(page); + await page.locator('input[name="name"]').fill('My API'); + await choose(page, 'OpenAI API'); + await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real'); + await page + .locator('input[name="base_url"]') + .fill('https://api.example.test/v1'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('My API', { exact: true })).toBeVisible(); + expect(state.providers[0]).toMatchObject({ + requester: 'openai', + api_keys: ['fixture-api-key-not-real'], + base_url: 'https://api.example.test/v1', + }); + expect(state.starts).toBe(0); +}); diff --git a/web/tests/e2e/provider-delete-footer.spec.ts b/web/tests/e2e/provider-delete-footer.spec.ts new file mode 100644 index 000000000..49d696a9e --- /dev/null +++ b/web/tests/e2e/provider-delete-footer.spec.ts @@ -0,0 +1,340 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: no real provider/model deletion or subscription authentication. +async function fixture(page: Page, requester = 'openai', empty = false) { + await installLangBotApiMocks(page, { authenticated: true }); + const provider = { + uuid: 'provider-delete-fixture', + name: 'Delete fixture provider', + requester, + base_url: 'https://example.test/v1', + api_keys: [], + llm_count: empty ? 0 : 1, + embedding_count: empty ? 0 : 1, + rerank_count: empty ? 0 : 1, + }; + const state = { + deleted: false, + fail: false, + deletes: [] as string[], + reads: [] as string[], + release: undefined as (() => void) | undefined, + hold: false, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (method === 'DELETE') { + state.deletes.push(path + url.search); + if (state.hold) + await new Promise((resolve) => { + state.release = resolve; + }); + if (state.fail) + return route.fulfill({ + status: 409, + json: { code: 409, msg: 'Fixture deletion blocked; try again.' }, + }); + state.deleted = true; + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai', 'openai-codex'].map((name) => ({ + name, + label: { en_US: name }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm', 'embedding', 'rerank'], + config: [], + }, + })), + }); + if (method === 'GET') state.reads.push(path + url.search); + if (path.endsWith('/providers')) + return ok(route, { providers: state.deleted ? [] : [provider] }); + if (path.endsWith('/codex/status')) + return ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + if (path.includes('/models/')) { + const type = path.split('/').pop(); + return ok(route, { + models: state.deleted + ? [] + : [ + { + uuid: `fixture-${type}`, + name: `Fixture ${type} model`, + provider_uuid: provider.uuid, + provider, + abilities: [], + extra_args: {}, + }, + ], + }); + } + if (path.endsWith(provider.uuid)) return ok(route, { provider }); + return ok(route, {}); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + return state; +} +const editDialog = (page: Page) => + page.locator('[role="dialog"]').filter({ + has: page.locator('[data-slot="dialog-title"]', { + hasText: /^Edit Provider$/, + }), + }); +async function edit(page: Page) { + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card.getByRole('button', { name: 'Expand', exact: true }).click(); + await expect( + card.getByText('Fixture llm model', { exact: true }), + ).toBeVisible(); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }) + .click(); + await expect(editDialog(page).locator('input[name="name"]')).toHaveValue( + 'Delete fixture provider', + ); +} + +for (const width of [1280, 320]) { + test(`confirmation stays centered throughout entry (${width}px)`, async ({ + page, + }) => { + const state = await fixture(page); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + // Trigger without Playwright's post-click wait so the browser animation is + // still live. Sample its actual keyframes, not only the final screenshot. + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .evaluate((el) => (el as HTMLButtonElement).click()); + const confirmation = page.getByRole('alertdialog'); + for (const phase of ['entry']) { + const samples = await confirmation.evaluate(async (el) => { + const animations = el.getAnimations(); + if (!animations.length) + throw new Error('Expected the real dialog animation'); + await Promise.all(animations.map((a) => a.ready)); + animations.forEach((a) => a.pause()); + const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => { + animations.forEach((a) => { + a.currentTime = Number(a.effect!.getTiming().duration) * fraction; + }); + const r = el.getBoundingClientRect(); + return { + x: r.x + r.width / 2, + y: r.y + r.height / 2, + left: r.left, + right: r.right, + }; + }); + animations.forEach((a) => a.finish()); + return samples; + }); + for (const sample of samples) { + expect( + Math.abs(sample.x - width / 2), + `${phase} horizontal center`, + ).toBeLessThan(1); + expect( + Math.abs(sample.y - 450), + `${phase} vertical center`, + ).toBeLessThan(1); + expect(sample.left).toBeGreaterThanOrEqual(0); + expect(sample.right).toBeLessThanOrEqual(width); + } + } + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + expect(state.deletes).toEqual([]); + }); +} + +for (const requester of ['openai', 'openai-codex']) { + for (const width of [1280, 320]) { + test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + const dialog = editDialog(page); + const footer = dialog.locator('[data-slot="dialog-footer"]'); + const remove = footer.getByRole('button', { + name: 'Delete', + exact: true, + }); + await expect(remove).toBeVisible(); + for (const button of await footer.getByRole('button').all()) { + await expect(button).toBeInViewport({ ratio: 1 }); + const box = await button.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + } + const left = await remove.boundingBox(); + const cancel = await footer + .getByRole('button', { name: 'Cancel', exact: true }) + .boundingBox(); + expect(left!.x + left!.width).toBeLessThan(cancel!.x); + await remove.click(); + const confirmation = page.getByRole('alertdialog'); + await expect(confirmation).toContainText('this provider and ALL models'); + await expect(confirmation).toContainText('cannot be undone'); + await expect(confirmation).toBeInViewport({ ratio: 1 }); + await confirmation.evaluate(async (element) => { + await Promise.all( + element.getAnimations().map((animation) => animation.finished), + ); + }); + const box = await confirmation.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + await expect(dialog).toBeVisible(); + expect(state.deletes).toEqual([]); + }); + } + test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + state.hold = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect.poll(() => state.deletes.length).toBe(1); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeDisabled(); + await expect( + confirmation.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeDisabled(); + await expect( + editDialog(page).getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + includeHidden: true, + }), + ).toBeDisabled(); + await page.keyboard.press('Escape'); + await expect(confirmation).toBeVisible(); + state.reads = []; + state.release!(); + await expect(editDialog(page)).toHaveCount(0); + await expect( + page.getByText('Delete fixture provider', { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText('Fixture llm model', { exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture?cascade=true', + ]); + expect(state.reads).toContain('/api/v1/provider/providers'); + }); +} + +test('failed cascade retains readable error and can retry', async ({ + page, +}) => { + const state = await fixture(page); + await edit(page); + state.fail = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(confirmation.getByRole('alert')).toContainText( + 'Fixture deletion blocked; try again.', + ); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect(editDialog(page)).toBeVisible(); + state.fail = false; + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(editDialog(page)).toHaveCount(0); + expect(state.deletes).toHaveLength(2); +}); + +test('new providers do not expose footer deletion', async ({ page }) => { + const state = await fixture(page); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await expect( + page + .getByRole('dialog', { name: 'Add Provider', exact: true }) + .getByRole('button', { name: 'Delete', exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('system-managed provider has no edit or delete entry', async ({ + page, +}) => { + const state = await fixture(page, 'space-chat-completions'); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await expect(card).toBeVisible(); + await expect(card.locator('svg.lucide-settings')).toHaveCount(0); + await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('existing empty-provider card delete keeps its non-cascade request', async ({ + page, +}) => { + const state = await fixture(page, 'openai', true); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-trash-2') }) + .click(); + await expect( + page.getByText('Are you sure you want to delete this provider?', { + exact: true, + }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(card).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture', + ]); +}); diff --git a/web/tests/e2e/provider-dropdown.spec.ts b/web/tests/e2e/provider-dropdown.spec.ts new file mode 100644 index 000000000..9424cc713 --- /dev/null +++ b/web/tests/e2e/provider-dropdown.spec.ts @@ -0,0 +1,177 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: never authenticate or write a real provider. +test.use({ hasTouch: true }); +for (const width of [1280, 390, 320]) { + test(`provider dropdown bounded without dialog growth (${width}px)`, async ({ + page, + }, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + const data = path.endsWith('/requesters') + ? { + requesters: Array.from({ length: 30 }, (_, i) => ({ + name: i === 0 ? 'openai-codex' : `provider-${i}`, + label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + config: [], + support_type: ['llm'], + }, + })), + } + : { providers: [], models: [] }; + await route.fulfill({ json: { code: 0, data } }); + }); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page + .getByRole('button', { name: 'Add Provider', exact: true }) + .click(); + await page.setViewportSize({ width, height: 720 }); + const trigger = page.getByRole('button', { + name: 'Select Provider Type', + exact: true, + }); + const dialog = page + .locator('[role="dialog"]') + .filter({ has: page.locator('input[name="name"]') }); + await trigger.scrollIntoViewIfNeeded(); + const before = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + await trigger.click(); + const search = page.getByPlaceholder('Search providers...'); + await expect(search).toBeFocused(); + const menu = search.locator('../..'); + await expect( + page.getByRole('button', { name: 'Provider 29', exact: false }), + ).toBeAttached(); + await menu.evaluate(async (el) => { + await Promise.all(el.getAnimations().map((a) => a.finished)); + }); + const options = menu.locator(':scope > div').last(); + await options.hover(); + await page.mouse.wheel(0, 1200); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + if (width < 1280) { + await page.mouse.wheel(0, -1200); + await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0); + const box = (await options.boundingBox())!; + const session = await page.context().newCDPSession(page); + const x = box.x + box.width / 2; + const y = box.y + box.height - 30; + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y }], + }); + for (let step = 1; step <= 10; step++) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: y - step * 18 }], + }); + } + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await session.detach(); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + } + const geometry = await menu.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const list = el.lastElementChild as HTMLElement; + const clipped: string[] = []; + for ( + let parent = el.parentElement; + parent; + parent = parent.parentElement + ) { + const bounds = parent.getBoundingClientRect(); + if ( + /(auto|scroll|hidden|clip)/.test( + getComputedStyle(parent).overflowY, + ) && + (rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1) + ) + clipped.push(parent.tagName); + } + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + clipped, + listHeight: list.clientHeight, + listScroll: list.scrollHeight, + scrollTop: list.scrollTop, + documentWidth: document.documentElement.scrollWidth, + }; + }); + const after = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir; + mkdirSync(dir, { recursive: true }); + await page.screenshot({ + path: `${dir}/dropdown-${width}.png`, + fullPage: true, + }); + writeFileSync( + `${dir}/dropdown-${width}.json`, + JSON.stringify( + { evidence: 'UI fixture only', width, before, after, geometry }, + null, + 2, + ), + ); + expect.soft(after).toEqual(before); + expect.soft(geometry.clipped).toEqual([]); + expect.soft(geometry.left).toBeGreaterThanOrEqual(0); + expect.soft(geometry.right).toBeLessThanOrEqual(width); + expect.soft(geometry.top).toBeGreaterThanOrEqual(0); + expect.soft(geometry.bottom).toBeLessThanOrEqual(720); + expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width); + expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight); + expect(geometry.scrollTop).toBeGreaterThan(0); + await page.keyboard.press('Escape'); + await expect(search).toBeHidden(); + await expect(dialog).toBeVisible(); + await expect(trigger).toBeFocused(); + await trigger.click(); + await search.fill('Provider 29'); + await page.locator('input[name="name"]').click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="name"]')).toBeFocused(); + await trigger.click(); + await expect(search).toHaveValue(''); + await search.fill('Codex'); + await page + .getByRole('button', { name: 'OpenAI Codex', exact: false }) + .click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Save and sign in', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'OpenAI Codex', exact: false }), + ).toBeFocused(); + }); +} diff --git a/web/tests/e2e/provider-edit-loading.spec.ts b/web/tests/e2e/provider-edit-loading.spec.ts new file mode 100644 index 000000000..e73a59196 --- /dev/null +++ b/web/tests/e2e/provider-edit-loading.spec.ts @@ -0,0 +1,262 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All API traffic is intercepted; no real provider secrets or mutations. +async function fixture(page: Page, requester = 'openai') { + await installLangBotApiMocks(page, { authenticated: true }); + const providers = ['alpha', 'beta'].map((id) => ({ + uuid: `loading-${id}`, + name: `Loading fixture ${id}`, + requester, + base_url: `https://${id}.example.test/v1`, + api_keys: [`fixture-key-${id}`], + llm_count: 0, + embedding_count: 0, + rerank_count: 0, + })); + const state = { + hold: '' as '' | 'detail' | 'requesters', + fail: '' as '' | 'detail' | 'requesters', + held: [] as { release: () => void; finished: Promise }[], + reads: [] as string[], + mutations: [] as string[], + errors: [] as string[], + }; + page.on('pageerror', (error) => state.errors.push(error.message)); + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() !== 'GET') { + state.mutations.push(route.request().method() + ' ' + path); + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + state.reads.push(path); + const provider = providers.find((p) => path.endsWith('/' + p.uuid)); + const dependency = path.endsWith('/requesters') + ? 'requesters' + : provider + ? 'detail' + : ''; + const fail = dependency && state.fail === dependency; + let finish: (() => void) | undefined; + if (dependency && state.hold === dependency) { + const finished = new Promise((resolve) => { + finish = resolve; + }); + await new Promise((release) => + state.held.push({ release, finished }), + ); + } + try { + if (fail) + return await route.fulfill({ + status: 503, + json: { code: 503, msg: `Fixture ${dependency} unavailable` }, + }); + if (dependency === 'requesters') + return await ok(route, { + requesters: [ + { + name: requester, + label: { + en_US: + requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [], + }, + }, + ], + }); + if (provider) return await ok(route, { provider }); + if (path.endsWith('/providers')) return await ok(route, { providers }); + if (path.endsWith('/codex/status')) + return await ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + return await ok(route, { models: [] }); + } finally { + finish?.(); + } + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await expect( + page.getByText(providers[0].name, { exact: true }), + ).toBeVisible(); + // Let the panel's independent requester-support read finish before gating the form. + await expect + .poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length) + .toBeGreaterThanOrEqual(1); + return state; +} + +const dialog = (page: Page) => + page.getByRole('dialog', { name: 'Edit Provider', exact: true }); +const editButton = (page: Page, id = 'alpha') => + page + .locator('[data-slot="card"]') + .filter({ hasText: `Loading fixture ${id}` }) + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }); + +async function expectLoading(page: Page) { + const form = dialog(page); + await expect(form.getByRole('status')).toContainText('Loading...'); + await expect( + form.getByRole('status').locator('svg.animate-spin'), + ).toBeVisible(); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); +} + +async function expectReady(page: Page, id = 'alpha', requester = 'openai') { + const form = dialog(page); + await expect(form.locator('input[name="name"]')).toHaveValue( + `Loading fixture ${id}`, + ); + await expect( + form.getByRole('status', { name: 'Loading...', exact: true }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + }), + ).toBeEnabled(); + if (requester === 'openai') { + await expect(form.locator('input[name="base_url"]')).toHaveValue( + `https://${id}.example.test/v1`, + ); + await expect(form.locator('input[name="api_key"]')).toHaveValue( + `fixture-key-${id}`, + ); + await expect( + form.getByRole('button', { name: /OpenAI fixture/ }), + ).toBeVisible(); + } else { + await expect(form.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /Codex fixture/ }), + ).toBeVisible(); + } +} + +for (const requester of ['openai', 'openai-codex']) { + for (const dependency of ['detail', 'requesters'] as const) { + test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({ + page, + }) => { + const state = await fixture(page, requester); + state.hold = dependency; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + // Remain gated for the whole delay, not just the first render. + await page.waitForTimeout(250); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, 'alpha', requester); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} + +for (const dependency of ['detail', 'requesters'] as const) { + test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({ + page, + }) => { + const state = await fixture(page); + state.fail = dependency; + await editButton(page).click(); + const form = dialog(page); + await expect(form.getByRole('alert')).toContainText('Failed to load data'); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Retry', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); + state.fail = ''; + state.hold = dependency; + await form.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + state.fail = dependency; + await editButton(page).click(); + await expect(form.getByRole('alert')).toBeVisible(); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); +} + +for (const next of ['alpha', 'beta']) { + for (const staleFailure of [false, true]) { + test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({ + page, + }) => { + const state = await fixture(page); + state.hold = 'detail'; + state.fail = staleFailure ? 'detail' : ''; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + const staleRequests = state.held.splice(0); + await dialog(page) + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + state.fail = ''; + // Reopen during the closing animation, before Radix's retained content unmounts. + await editButton(page, next).dispatchEvent('click'); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, next); + await dialog(page) + .locator('input[name="name"]') + .fill('Unsaved fixture edit'); + staleRequests.forEach((request) => request.release()); + await Promise.all(staleRequests.map((request) => request.finished)); + await page.waitForTimeout(250); + await expect(dialog(page).locator('input[name="name"]')).toHaveValue( + 'Unsaved fixture edit', + ); + await expect(dialog(page).getByRole('alert')).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} diff --git a/web/tests/unit/codex-subscription.test.mjs b/web/tests/unit/codex-subscription.test.mjs new file mode 100644 index 000000000..6d8d9bbdf --- /dev/null +++ b/web/tests/unit/codex-subscription.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => { + const directory = new URL('../../src/i18n/locales/', import.meta.url); + let expected; + for (const file of fs.readdirSync(directory)) { + const compiled = ts.transpileModule( + fs.readFileSync(new URL(file, directory), 'utf8'), + { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }, + ).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + const catalog = (module.exports.default || Object.values(module.exports)[0]) + .models.codex; + const keys = Object.keys(catalog).sort(); + expected ??= keys; + assert.deepEqual(keys, expected, file); + assert.equal(keys.length, 26, file); + assert.ok(catalog.expiresAt.includes('{{time}}'), file); + } +}); + +function policy() { + const source = fs.readFileSync( + new URL( + '../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts', + import.meta.url, + ), + 'utf8', + ); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + return module.exports; +} + +test('Codex payload discards previously entered API credentials and URL', () => { + const { providerPayload } = policy(); + assert.deepEqual( + providerPayload({ + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://proxy.invalid', + api_key: 'fixture-only', + }), + { + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }, + ); +}); + +test('ordinary providers preserve API key and base URL behavior', () => { + assert.deepEqual( + policy().providerPayload({ + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_key: 'fixture-only', + }), + { + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_keys: ['fixture-only'], + }, + ); +}); + +test('poll delay honors upstream minimum and transient backoff', () => { + const { pollDelay } = policy(); + assert.equal(pollDelay(5, 0), 5000); + assert.equal(pollDelay(10, 2), 40000); + assert.equal(pollDelay(120, 3), 120000); + assert.equal(pollDelay(NaN, 0), 5000); + assert.equal(pollDelay(-1, 0), 5000); +}); + +test('only the contracted OpenAI device authorization URL can be opened', () => { + const { isCodexVerificationUri } = policy(); + assert.equal( + isCodexVerificationUri('https://auth.openai.com/codex/device'), + true, + ); + for (const url of [ + 'javascript:alert(1)', + 'https://auth.openai.com.evil.test/codex/device', + 'https://evil.test', + 'https://user@auth.openai.com/codex/device', + ]) { + assert.equal(isCodexVerificationUri(url), false); + } +}); From bc32eb3ca035ea1ec12ae94820f44b98e981ea74 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Mon, 7 Sep 2026 14:22:27 +0800 Subject: [PATCH 21/56] feat(api): add system context endpoint for lbctl --- docs/API_KEY_AUTH.md | 17 +++++ skills/skills/langbot-mcp-ops/SKILL.md | 2 + .../pkg/api/http/controller/groups/system.py | 11 ++++ tests/integration/api/test_workspaces.py | 64 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 49d80b6f9..f825ea0a5 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -88,6 +88,23 @@ Each endpoint accepts **either**: 1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users 2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services +### Inspecting API Key Identity + +`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "instance_uuid": "...", + "workspace_uuid": "...", + "api_key_id": "...", + "permissions": ["..."] + } +} +``` + ## Example: Model Management ### List All LLM Models diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 7480f2b1a..41236bb32 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -43,6 +43,8 @@ Two kinds of key are accepted: Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose scopes do not authorize a tool gets `403 Forbidden`. +To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key. + ## Client configuration ```json diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 9ae0e0bf2..a8be6ba22 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -15,6 +15,17 @@ from .....workspace.invitation_delivery import InvitationDeliveryService @group.group_class('system', '/api/v1/system') class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY) + async def _(request_context: RequestContext) -> str: + return self.success( + data={ + 'instance_uuid': request_context.instance_uuid, + 'workspace_uuid': request_context.workspace_uuid, + 'api_key_id': request_context.principal.api_key_uuid, + 'permissions': sorted(request_context.workspace.permissions), + } + ) + @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: # Read wizard_status and wizard_progress from metadata table diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 04e798ca4..511607f0d 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -440,6 +440,70 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac assert (await forbidden.get_json())['code'] == 'permission_denied' +async def test_api_key_context_returns_bound_identity_without_workspace_permission(workspace_api): + application, client, _, owner_token = workspace_api + current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token)) + workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid'] + + create_response = await client.post( + '/api/v1/apikeys', + headers=_auth(owner_token, workspace_uuid), + json={'name': 'Context probe', 'scopes': []}, + ) + assert create_response.status_code == 200 + created = (await create_response.get_json())['data']['key'] + + missing_auth = await client.get('/api/v1/system/context') + assert missing_auth.status_code == 401 + + invalid_auth = await client.get( + '/api/v1/system/context', + headers={'X-API-Key': 'lbk_invalid'}, + ) + assert invalid_auth.status_code == 401 + + response = await client.get( + '/api/v1/system/context', + headers={ + 'X-API-Key': created['key'], + 'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data'] == { + 'instance_uuid': application.workspace_service.instance_uuid, + 'workspace_uuid': workspace_uuid, + 'api_key_id': created['uuid'], + 'permissions': [], + } + + bearer_response = await client.get( + '/api/v1/system/context', + headers={'Authorization': f'Bearer {created["key"]}'}, + ) + assert bearer_response.status_code == 200 + assert (await bearer_response.get_json())['data']['api_key_id'] == created['uuid'] + + jwt_response = await client.get( + '/api/v1/system/context', + headers={'Authorization': f'Bearer {owner_token}'}, + ) + assert jwt_response.status_code == 401 + + revoke_response = await client.delete( + f'/api/v1/apikeys/{created["id"]}', + headers=_auth(owner_token, workspace_uuid), + ) + assert revoke_response.status_code == 200 + + revoked_response = await client.get( + '/api/v1/system/context', + headers={'X-API-Key': created['key']}, + ) + assert revoked_response.status_code == 401 + + async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core( workspace_api, ): From d6443b10bcf319c59a826573cbabcd6353b9b3a9 Mon Sep 17 00:00:00 2001 From: QuasarRyan Date: Mon, 7 Sep 2026 22:46:47 +0800 Subject: [PATCH 22/56] feat(platform): add Mattermost platform adapter (#2515) --- .../pkg/platform/sources/mattermost.py | 375 ++++++++++++++++++ .../pkg/platform/sources/mattermost.svg | 1 + .../pkg/platform/sources/mattermost.yaml | 75 ++++ .../platform/test_mattermost_adapter.py | 169 ++++++++ 4 files changed, 620 insertions(+) create mode 100644 src/langbot/pkg/platform/sources/mattermost.py create mode 100644 src/langbot/pkg/platform/sources/mattermost.svg create mode 100644 src/langbot/pkg/platform/sources/mattermost.yaml create mode 100644 tests/unit_tests/platform/test_mattermost_adapter.py diff --git a/src/langbot/pkg/platform/sources/mattermost.py b/src/langbot/pkg/platform/sources/mattermost.py new file mode 100644 index 000000000..d9ae84c2c --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import asyncio +import json +import re +import typing +from urllib.parse import urlsplit, urlunsplit + +import aiohttp + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +_MATTERMOST_MAX_POST_LENGTH = 16_383 +_MENTION_BOUNDARY = r'(? str: + """Return a validated Mattermost server URL without a trailing slash.""" + + url = server_url.strip().rstrip('/') + parsed = urlsplit(url) + if parsed.scheme not in {'http', 'https'} or not parsed.netloc: + raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL') + return url + + +def _websocket_url(server_url: str) -> str: + parsed = urlsplit(server_url) + scheme = 'wss' if parsed.scheme == 'https' else 'ws' + return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', '')) + + +class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Translate Mattermost post text to and from LangBot message chains.""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> str: + parts: list[str] = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + parts.append(component.text) + elif isinstance(component, platform_message.Image) and component.url: + # Mattermost renders image URLs in Markdown messages. + parts.append(component.url) + elif isinstance(component, platform_message.File) and component.url: + parts.append(component.url) + return ''.join(parts) + + @staticmethod + async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain: + text = str(post.get('message') or '') + components: list[typing.Any] = [ + platform_message.Source( + id=str(post.get('id') or ''), + time=float(post.get('create_at') or 0) / 1000, + ) + ] + if bot_username: + mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE) + if mention_pattern.search(text): + components.append(platform_message.At(target=bot_username)) + text = mention_pattern.sub('', text).strip() + if text: + components.append(platform_message.Plain(text=text)) + return platform_message.MessageChain(components) + + +class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> dict: + return event.source_platform_object + + @staticmethod + async def target2yiri( + post: dict, + channel: dict, + sender_name: str, + bot_username: str, + ) -> platform_events.MessageEvent: + message_chain = await MattermostMessageConverter.target2yiri(post, bot_username) + timestamp = float(post.get('create_at') or 0) / 1000 + sender_id = str(post.get('user_id') or '') + channel_type = channel.get('type') + + if channel_type == 'D': + return platform_events.FriendMessage( + sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''), + message_chain=message_chain, + time=timestamp, + source_platform_object={'post': post, 'channel': channel}, + ) + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_id, + member_name=sender_name or sender_id, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=str(post.get('channel_id') or ''), + name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''), + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=timestamp, + source_platform_object={'post': post, 'channel': channel}, + ) + + +class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Mattermost Bot Account adapter using the v4 REST and WebSocket APIs.""" + + server_url: str = '' + access_token: str = '' + session: aiohttp.ClientSession | None = None + listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {} + channel_cache: dict[str, dict] = {} + stream_post_ids: dict[str, str] = {} + bot_username: str = '' + _running: bool = False + + message_converter: MattermostMessageConverter = MattermostMessageConverter() + event_converter: MattermostEventConverter = MattermostEventConverter() + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + server_url = _normalize_server_url(str(config.get('server_url') or '')) + access_token = str(config.get('access_token') or '').strip() + if not access_token: + raise ValueError('Mattermost adapter requires an access_token') + + super().__init__( + config=config, + logger=logger, + server_url=server_url, + access_token=access_token, + bot_account_id='', + session=None, + listeners={}, + channel_cache={}, + stream_post_ids={}, + bot_username='', + _running=False, + ) + + async def _get_session(self) -> aiohttp.ClientSession: + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession( + headers={'Authorization': f'Bearer {self.access_token}'}, + raise_for_status=False, + ) + return self.session + + async def _api_request( + self, + method: str, + path: str, + *, + payload: dict | None = None, + ) -> dict: + session = await self._get_session() + async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response: + raw_body = await response.text() + if response.status >= 400: + # Mattermost returns a useful JSON error, but never include request headers/tokens in errors. + try: + error = json.loads(raw_body).get('message', raw_body) + except json.JSONDecodeError: + error = raw_body + raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}') + if not raw_body: + return {} + return json.loads(raw_body) + + async def _load_bot_identity(self) -> None: + user = await self._api_request('GET', '/users/me') + self.bot_account_id = str(user.get('id') or '') + self.bot_username = str(user.get('username') or '') + if not self.bot_account_id: + raise RuntimeError('Mattermost API did not return a bot user ID') + + async def _get_channel(self, channel_id: str) -> dict: + if channel_id not in self.channel_cache: + self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}') + return self.channel_cache[channel_id] + + async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict: + if not text: + return {} + if len(text) > _MATTERMOST_MAX_POST_LENGTH: + raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters') + payload = {'channel_id': channel_id, 'message': text} + if root_id: + payload['root_id'] = root_id + return await self._api_request('POST', '/posts', payload=payload) + + async def _get_direct_channel_id(self, user_id: str) -> str: + if not self.bot_account_id: + await self._load_bot_identity() + channel = await self._api_request( + 'POST', + '/channels/direct', + payload={'user_ids': [self.bot_account_id, user_id]}, + ) + channel_id = str(channel.get('id') or '') + if not channel_id: + raise RuntimeError('Mattermost did not return a direct-message channel ID') + self.channel_cache[channel_id] = channel + return channel_id + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + if target_type not in {'person', 'group'}: + raise ValueError("Mattermost target_type must be 'person' or 'group'") + text = await self.message_converter.yiri2target(message) + channel_id = str(target_id) + if target_type == 'person': + channel_id = await self._get_direct_channel_id(channel_id) + await self._post_message(channel_id, text) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + source = await self.event_converter.yiri2target(message_source) + post = source['post'] + text = await self.message_converter.yiri2target(message) + # A message received inside a Mattermost thread must remain in that thread. When + # quote_origin is requested, make the response a reply to the source root post. + root_id = str(post.get('root_id') or '') + if quote_origin and not root_id: + root_id = str(post.get('id') or '') + await self._post_message(str(post['channel_id']), text, root_id) + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + source = await self.event_converter.yiri2target(event) + post = source['post'] + root_id = str(post.get('root_id') or post.get('id') or '') + created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id) + if created.get('id'): + self.stream_post_ids[str(message_id)] = str(created['id']) + return True + return False + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + response_id = str(bot_message.resp_message_id) + text = await self.message_converter.yiri2target(message) + if not text: + return + + post_id = self.stream_post_ids.get(response_id) + if post_id: + await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text}) + else: + source = await self.event_converter.yiri2target(message_source) + post = source['post'] + root_id = str(post.get('root_id') or '') + if quote_origin and not root_id: + root_id = str(post.get('id') or '') + created = await self._post_message(str(post['channel_id']), text, root_id) + post_id = str(created.get('id') or '') + if post_id: + self.stream_post_ids[response_id] = post_id + + if is_final and getattr(bot_message, 'tool_calls', None) is None: + self.stream_post_ids.pop(response_id, None) + + async def is_stream_output_supported(self) -> bool: + return bool(self.config.get('enable_stream_reply', True)) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners.pop(event_type, None) + + async def _dispatch_post(self, payload: dict) -> None: + data = payload.get('data') or {} + try: + post = json.loads(data.get('post') or '{}') + except (TypeError, json.JSONDecodeError): + await self.logger.error('Mattermost received a posted event with an invalid post payload') + return + + if not post or str(post.get('user_id') or '') == self.bot_account_id: + return + channel_id = str(post.get('channel_id') or '') + if not channel_id: + return + + try: + channel = await self._get_channel(channel_id) + event = await self.event_converter.target2yiri( + post, + channel, + str(data.get('sender_name') or post.get('user_id') or ''), + self.bot_username, + ) + callback = self.listeners.get(type(event)) + if callback: + result = callback(event, self) + if asyncio.iscoroutine(result): + await result + except Exception as exc: + await self.logger.error(f'Error handling Mattermost post: {exc}') + + async def _run_websocket_once(self) -> None: + session = await self._get_session() + async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket: + await websocket.send_json( + { + 'seq': 1, + 'action': 'authentication_challenge', + 'data': {'token': self.access_token}, + } + ) + async for message in websocket: + if message.type == aiohttp.WSMsgType.TEXT: + try: + payload = json.loads(message.data) + except json.JSONDecodeError: + continue + if payload.get('event') == 'posted': + await self._dispatch_post(payload) + elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}: + break + + async def run_async(self): + self._running = True + await self._load_bot_identity() + await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})') + + retry_delay = 1 + while self._running: + try: + await self._run_websocket_once() + retry_delay = 1 + except asyncio.CancelledError: + raise + except Exception as exc: + if self._running: + await self.logger.error(f'Mattermost WebSocket disconnected: {exc}') + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 30) + + async def kill(self) -> bool: + self._running = False + if self.session and not self.session.closed: + await self.session.close() + return True diff --git a/src/langbot/pkg/platform/sources/mattermost.svg b/src/langbot/pkg/platform/sources/mattermost.svg new file mode 100644 index 000000000..d185ef27d --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/mattermost.yaml b/src/langbot/pkg/platform/sources/mattermost.yaml new file mode 100644 index 000000000..391f06902 --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.yaml @@ -0,0 +1,75 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: mattermost + label: + en_US: Mattermost + zh_Hans: Mattermost + zh_Hant: Mattermost + ja_JP: Mattermost + th_TH: Mattermost + vi_VN: Mattermost + es_ES: Mattermost + icon: mattermost.svg + description: + en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this. + zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。 + zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。 + ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。 + th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป + vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn. + es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio. +spec: + categories: + - global + - popular + config: + - name: server_url + label: + en_US: Mattermost Server URL + zh_Hans: Mattermost 服务器地址 + zh_Hant: 位址伺服器 Mattermost + ja_JP: Mattermost サーバー URL + th_TH: URL เซิร์ฟเวอร์ Mattermost + vi_VN: URL máy chủ Mattermost + es_ES: URL del servidor Mattermost + description: + en_US: The base URL of the Mattermost server, for example https://mattermost.example.com + zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com + type: string + required: true + default: "" + - name: access_token + label: + en_US: Bot Access Token + zh_Hans: Bot 访问令牌 + zh_Hant: Bot 存取權杖 + ja_JP: Bot アクセストークン + th_TH: โทเค็นการเข้าถึงของบอต + vi_VN: Mã truy cập Bot + es_ES: Token de acceso del bot + description: + en_US: The personal access token generated for the Mattermost Bot Account + zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌 + type: string + required: true + default: "" + - name: enable_stream_reply + label: + en_US: Enable Stream Reply + zh_Hans: 启用流式回复 + zh_Hant: 啟用串流回覆 + ja_JP: ストリーミング返信を有効化 + th_TH: เปิดใช้งานการตอบกลับแบบสตรีม + vi_VN: Bật phản hồi luồng + es_ES: Activar respuesta en streaming + description: + en_US: Update a Mattermost post while LangBot generates a response + zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息 + type: boolean + required: false + default: true +execution: + python: + path: ./mattermost.py + attr: MattermostAdapter diff --git a/tests/unit_tests/platform/test_mattermost_adapter.py b/tests/unit_tests/platform/test_mattermost_adapter.py new file mode 100644 index 000000000..6830ac6b6 --- /dev/null +++ b/tests/unit_tests/platform/test_mattermost_adapter.py @@ -0,0 +1,169 @@ +from types import SimpleNamespace + +import pytest + +from langbot.pkg.platform.sources.mattermost import ( + MattermostAdapter, + MattermostEventConverter, + MattermostMessageConverter, + _normalize_server_url, + _websocket_url, +) +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class StubLogger: + async def info(self, *_args, **_kwargs): + pass + + async def error(self, *_args, **_kwargs): + pass + + +def _adapter() -> MattermostAdapter: + return MattermostAdapter.model_construct( + config={'enable_stream_reply': True}, + logger=StubLogger(), + server_url='https://mattermost.example.com', + access_token='secret', + bot_account_id='bot-id', + bot_username='langbot', + session=None, + listeners={}, + channel_cache={}, + stream_post_ids={}, + _running=False, + ) + + +def test_server_and_websocket_urls_preserve_subpath(): + server_url = _normalize_server_url('https://example.com/chat/') + assert server_url == 'https://example.com/chat' + assert _websocket_url(server_url) == 'wss://example.com/chat/api/v4/websocket' + + with pytest.raises(ValueError, match='absolute HTTP'): + _normalize_server_url('mattermost.example.com') + + +@pytest.mark.asyncio +async def test_converter_marks_and_removes_bot_mention(): + chain = await MattermostMessageConverter.target2yiri( + {'id': 'post-1', 'create_at': 1_000, 'message': '@langbot hello'}, + 'langbot', + ) + + assert any(isinstance(item, platform_message.At) for item in chain) + assert any(isinstance(item, platform_message.Plain) and item.text == 'hello' for item in chain) + + +@pytest.mark.asyncio +async def test_event_converter_distinguishes_direct_and_group_channels(): + post = {'id': 'post-1', 'channel_id': 'channel-1', 'user_id': 'user-1', 'message': 'hello', 'create_at': 1_000} + direct = await MattermostEventConverter.target2yiri(post, {'type': 'D'}, 'alice', 'langbot') + group = await MattermostEventConverter.target2yiri( + post, + {'type': 'O', 'display_name': 'General'}, + 'alice', + 'langbot', + ) + + assert isinstance(direct, platform_events.FriendMessage) + assert isinstance(group, platform_events.GroupMessage) + assert group.sender.group.name == 'General' + + +@pytest.mark.asyncio +async def test_send_to_person_creates_or_reuses_direct_channel(monkeypatch): + adapter = _adapter() + requests = [] + posted = [] + + async def api_request(method, path, *, payload=None): + requests.append((method, path, payload)) + return {'id': 'direct-channel', 'type': 'D'} + + async def post_message(channel_id, text, root_id=''): + posted.append((channel_id, text, root_id)) + return {'id': 'post-1'} + + monkeypatch.setattr(adapter, '_api_request', api_request) + monkeypatch.setattr(adapter, '_post_message', post_message) + + await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hello')])) + + assert requests == [('POST', '/channels/direct', {'user_ids': ['bot-id', 'user-1']})] + assert posted == [('direct-channel', 'hello', '')] + + +@pytest.mark.asyncio +async def test_reply_keeps_existing_thread(monkeypatch): + adapter = _adapter() + posted = [] + + async def post_message(channel_id, text, root_id=''): + posted.append((channel_id, text, root_id)) + return {'id': 'reply'} + + monkeypatch.setattr(adapter, '_post_message', post_message) + event = platform_events.GroupMessage.model_construct( + source_platform_object={ + 'post': {'id': 'post-1', 'channel_id': 'channel-1', 'root_id': 'thread-root'}, + 'channel': {'type': 'O'}, + } + ) + + await adapter.reply_message(event, platform_message.MessageChain([platform_message.Plain(text='reply')])) + + assert posted == [('channel-1', 'reply', 'thread-root')] + + +@pytest.mark.asyncio +async def test_stream_reply_updates_existing_post(monkeypatch): + adapter = _adapter() + adapter.stream_post_ids['response-1'] = 'post-1' + requests = [] + + async def api_request(method, path, *, payload=None): + requests.append((method, path, payload)) + return {'id': 'post-1'} + + monkeypatch.setattr(adapter, '_api_request', api_request) + message = SimpleNamespace(resp_message_id='response-1', tool_calls=None) + + await adapter.reply_message_chunk( + SimpleNamespace(), + message, + platform_message.MessageChain([platform_message.Plain(text='complete')]), + is_final=True, + ) + + assert requests == [('PUT', '/posts/post-1', {'id': 'post-1', 'message': 'complete'})] + assert 'response-1' not in adapter.stream_post_ids + + +@pytest.mark.asyncio +async def test_posted_event_dispatches_listener(monkeypatch): + adapter = _adapter() + received = [] + + async def get_channel(_channel_id): + return {'type': 'D'} + + async def listener(event, _adapter): + received.append(event) + + monkeypatch.setattr(adapter, '_get_channel', get_channel) + adapter.register_listener(platform_events.FriendMessage, listener) + + await adapter._dispatch_post( + { + 'data': { + 'sender_name': 'alice', + 'post': '{"id":"post-1","channel_id":"channel-1","user_id":"user-1","message":"hello","create_at":1000}', + } + } + ) + + assert len(received) == 1 + assert received[0].sender.nickname == 'alice' From 267232c24f93c515d6fd3f7f81c0676066ab1ab8 Mon Sep 17 00:00:00 2001 From: fishzjp <105406371+fishzjp@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:31:54 +0800 Subject: [PATCH 23/56] fix(security): harden password recovery with usable eight-character codes (#2477) Use eight securely random recovery-code characters with concurrency-safe online throttling. Preserve existing keys and verify recovery through browser and real SQLite integration tests. Co-authored-by: zhangjinpeng@mail.tuchong.com Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 48 ++- src/langbot/pkg/core/stages/genkeys.py | 21 +- .../api/test_recovery_password_journey.py | 85 +++++ .../api/test_user_reset_password.py | 302 ++++++++++++++++++ web/src/app/reset-password/page.tsx | 43 +-- web/tests/e2e/reset-password.spec.ts | 122 +++++++ 6 files changed, 588 insertions(+), 33 deletions(-) create mode 100644 tests/integration/api/test_recovery_password_journey.py create mode 100644 tests/unit_tests/api/test_user_reset_password.py create mode 100644 web/tests/e2e/reset-password.spec.ts diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 03c84bf47..844be406e 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -2,6 +2,8 @@ import quart import argon2 import asyncio import datetime +import hmac +import time import uuid from urllib.parse import parse_qs, urlsplit @@ -11,6 +13,33 @@ from ...context import RequestContext from .....cloud.launch import SpaceLaunchError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError +# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392). +# The admission check and slot bump share ONE synchronous critical section with no await +# points, so concurrent bursts within a single event loop cannot slip past accounting. +# Every admitted attempt consumes quota (regardless of success), which throttles both the +# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys. +# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream. +_MAX_RESET_ATTEMPTS_PER_WINDOW = 5 +_RESET_WINDOW_SECONDS = 15 * 60 + +_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0} + + +def _admit_reset_attempt(now: float) -> bool: + """Atomically reserve one reset-password admission slot. + + Must stay await-free: running to completion without suspension makes the + check-and-increment atomic under the single-threaded event loop. + """ + st = _reset_password_state + if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS: + st['window_started_at'] = now + st['attempts'] = 0 + if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW: + return False + st['attempts'] += 1 + return True + @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): @@ -81,6 +110,12 @@ class UserRouterGroup(group.RouterGroup): @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: + # Admit (or reject) BEFORE touching the body or any service call (#2392): + # rejecting requests never reach the slow path, and quota accounting happens + # synchronously at entry, closing the post-await race of burst requests. + if not _admit_reset_attempt(time.monotonic()): + return self.http_status(429, -1, 'Too many attempts, try again later') + json_data = await quart.request.json user_email = json_data['user'] @@ -98,7 +133,18 @@ class UserRouterGroup(group.RouterGroup): if user_obj is None: return self.http_status(400, -1, 'User not found') - if recovery_key != self.ap.instance_config.data['system']['recovery_key']: + stored_key = self.ap.instance_config.data['system']['recovery_key'] + try: + key_matches = ( + isinstance(recovery_key, str) + and isinstance(stored_key, str) + and hmac.compare_digest(recovery_key.encode(), stored_key.encode()) + ) + except UnicodeEncodeError: + # JSON can contain lone surrogates, which are not valid UTF-8. + key_matches = False + + if not key_matches: return self.http_status(403, -1, 'Invalid recovery key') await self.ap.user_service.reset_password(user_email, new_password) diff --git a/src/langbot/pkg/core/stages/genkeys.py b/src/langbot/pkg/core/stages/genkeys.py index f0412b9d2..230fa91f7 100644 --- a/src/langbot/pkg/core/stages/genkeys.py +++ b/src/langbot/pkg/core/stages/genkeys.py @@ -1,9 +1,18 @@ from __future__ import annotations +import logging import secrets from .. import stage, app +# This stage runs before SetupLoggerStage, so ap.logger is still None here; +# the module logger falls back to the stderr lastResort handler. +_logger = logging.getLogger(__name__) + +# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits. +_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' +_RECOVERY_KEY_LENGTH = 8 + @stage.stage_class('GenKeysStage') class GenKeysStage(stage.BootingStage): @@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage): ap.instance_config.data['system']['recovery_key'] = '' if not ap.instance_config.data['system']['recovery_key']: - ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() + # Keep recovery practical to type. Security also requires the reset + # endpoint's concurrency-safe quota (five admissions per 15 minutes). + ap.instance_config.data['system']['recovery_key'] = ''.join( + secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH) + ) await ap.instance_config.dump_config() + elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH: + _logger.warning( + 'Low-entropy legacy recovery key detected (length < 8); ' + 'regenerate system.recovery_key in the configuration file ' + 'with a strong random value (#2392)' + ) diff --git a/tests/integration/api/test_recovery_password_journey.py b/tests/integration/api/test_recovery_password_journey.py new file mode 100644 index 000000000..abaf3510e --- /dev/null +++ b/tests/integration/api/test_recovery_password_journey.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from quart import Quart + +from langbot.pkg.api.http.controller.groups import user as user_module +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.api.http.service.user import UserService +from langbot.pkg.core.stages.genkeys import GenKeysStage +from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.utils import constants +from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService +from langbot.pkg.workspace.service import WorkspaceService + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch): + """Exercise generation, reset, and old/new password login without mocked user services.""" + monkeypatch.setattr(constants, 'instance_id', 'recovery-journey') + monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0}) + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock())) + application = SimpleNamespace( + logger=logging.getLogger('recovery-password-journey'), + instance_config=SimpleNamespace( + data={ + 'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}}, + 'system': { + 'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600}, + 'recovery_key': '', + }, + }, + dump_config=AsyncMock(), + ), + ) + await GenKeysStage().run(application) + key = application.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') + persistence = PersistenceManager(application) + application.persistence_mgr = persistence + try: + await persistence.initialize() + application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey') + application.workspace_collaboration_service = WorkspaceCollaborationService( + application, application.workspace_service + ) + application.user_service = UserService(application) + quart_app = Quart(__name__) + await UserRouterGroup(application, quart_app).initialize() + client = quart_app.test_client() + + initial = await client.post( + '/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert initial.status_code == 200 + assert (await initial.get_json())['code'] == 0 + + payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'} + wrong = await client.post('/api/v1/user/reset-password', json=payload) + assert wrong.status_code == 403 + unchanged = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert (await unchanged.get_json())['code'] == 0 + + reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key}) + assert reset.status_code == 200 + assert (await reset.get_json())['code'] == 0 + old_login = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert (await old_login.get_json())['code'] != 0 + new_login = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'} + ) + new_data = await new_login.get_json() + assert new_data['code'] == 0 + assert new_data['data']['token'] + finally: + await persistence.get_db_engine().dispose() diff --git a/tests/unit_tests/api/test_user_reset_password.py b/tests/unit_tests/api/test_user_reset_password.py new file mode 100644 index 000000000..4ae70d90d --- /dev/null +++ b/tests/unit_tests/api/test_user_reset_password.py @@ -0,0 +1,302 @@ +"""Regression tests for recovery-key hardening (#2392). + +Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv: + +1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of + entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable. +2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure + counter across ``await`` points, so concurrent guesses all passed the gate + before any accounting happened; admission is now a synchronous fixed-window + quota consumed at entry, plus constant-time key comparison. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +from langbot.pkg.api.http.controller.groups import user as user_module +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.core.stages.genkeys import GenKeysStage + +pytestmark = pytest.mark.asyncio + +STORED_KEY = 'ABCD2345' + + +@pytest.fixture(autouse=True) +def _reset_quota_state(): + """Reset the module-level admission-quota state before each test.""" + user_module._reset_password_state['window_started_at'] = 0.0 + user_module._reset_password_state['attempts'] = 0 + yield + user_module._reset_password_state['window_started_at'] = 0.0 + user_module._reset_password_state['attempts'] = 0 + + +@pytest.fixture(autouse=True) +def _fast_sleep(monkeypatch): + """Neutralize the fixed 3s delay so tests run instantly.""" + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock())) + + +# --------------------------------------------------------------------------- +# genkeys.py: recovery-key generation and compatibility +# --------------------------------------------------------------------------- + + +def _make_genkeys_ap(existing_key: str) -> SimpleNamespace: + """Build a minimal Application mock for GenKeysStage. + + Mirrors the real boot order: no ``logger`` attribute is set because + GenKeysStage runs before SetupLoggerStage. + """ + return SimpleNamespace( + instance_config=SimpleNamespace( + data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}}, + dump_config=AsyncMock(), + ), + ) + + +async def test_recovery_key_generation_is_short_and_unambiguous(): + """Eight random base32 characters balance manual entry and online throttling.""" + ap = _make_genkeys_ap(existing_key='') + + await GenKeysStage().run(ap) + + key = ap.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') + assert ap.instance_config.dump_config.called + + +async def test_legacy_low_entropy_key_preserved_with_warning(caplog): + """A legacy 6-char key must keep working but emit a warning, without ap.logger.""" + ap = _make_genkeys_ap(existing_key='ABC123') + + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + + assert ap.instance_config.data['system']['recovery_key'] == 'ABC123' + assert any('Low-entropy' in record.message for record in caplog.records) + assert not ap.instance_config.dump_config.called + + +@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥']) +async def test_recovery_key_generation_preserves_existing_key(existing_key): + """An explicitly configured recovery key must not be regenerated on boot.""" + ap = _make_genkeys_ap(existing_key=existing_key) + + await GenKeysStage().run(ap) + + assert ap.instance_config.data['system']['recovery_key'] == existing_key + assert not ap.instance_config.dump_config.called + + +async def test_generated_key_is_preserved_without_legacy_warning(caplog): + """A restart must not warn about or replace the new eight-character key.""" + ap = _make_genkeys_ap(existing_key='') + await GenKeysStage().run(ap) + key = ap.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + ap.instance_config.dump_config.reset_mock() + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + assert ap.instance_config.data['system']['recovery_key'] == key + assert not caplog.records + ap.instance_config.dump_config.assert_not_awaited() + + +async def test_eight_character_key_does_not_trigger_legacy_warning(caplog): + ap = _make_genkeys_ap(existing_key='ABCD2345') + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + assert not caplog.records + + +# --------------------------------------------------------------------------- +# POST /api/v1/user/reset-password: admission quota + constant-time compare +# --------------------------------------------------------------------------- + + +async def _create_client(stored_key: str = STORED_KEY): + """Create a Quart test client with a mocked Application.""" + quart_app = quart.Quart(__name__) + + user_obj = SimpleNamespace(uuid='user-uuid', user='admin@example.com') + reset_password = AsyncMock() + get_user_by_email = AsyncMock(return_value=user_obj) + + ap = SimpleNamespace( + user_service=SimpleNamespace( + is_initialized=AsyncMock(return_value=True), + get_user_by_email=get_user_by_email, + reset_password=reset_password, + ), + instance_config=SimpleNamespace( + data={'system': {'recovery_key': stored_key}}, + ), + ) + + router = UserRouterGroup(ap, quart_app) + await router.initialize() + + client = quart_app.test_client() + return client, reset_password, get_user_by_email + + +def _payload(key: str = STORED_KEY) -> dict: + return {'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'} + + +@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥']) +async def test_correct_key_resets_password(key): + """New, legacy and explicitly configured keys all remain usable verbatim.""" + client, reset_password, _ = await _create_client(stored_key=key) + + resp = await client.post('/api/v1/user/reset-password', json=_payload(key)) + + assert resp.status_code == 200 + assert (await resp.get_json())['code'] == 0 + reset_password.assert_awaited_once_with('admin@example.com', 'NewPass1!') + + +async def test_wrong_key_rejected_without_reset(): + """A wrong recovery key returns 403 and never touches the password.""" + client, reset_password, _ = await _create_client() + + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + + assert resp.status_code == 403 + reset_password.assert_not_awaited() + + +async def test_non_string_recovery_key_does_not_crash(): + """Malformed recovery-key payloads must be rejected, not raise a 500. + + Constant-time comparison via hmac.compare_digest on bytes requires the + input to be a str; other JSON types must fail closed. + """ + client, reset_password, _ = await _create_client() + + resp = await client.post( + '/api/v1/user/reset-password', + json={'user': 'admin@example.com', 'recovery_key': 12345, 'new_password': 'NewPass1!'}, + ) + + assert resp.status_code == 403 + reset_password.assert_not_awaited() + + +@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff']) +async def test_non_ascii_recovery_key_does_not_crash(key): + """Non-ASCII keys must compare safely (encode-based constant-time compare).""" + client, _, _ = await _create_client() + + resp = await client.post( + '/api/v1/user/reset-password', + json={'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'}, + ) + + assert resp.status_code == 403 + + +async def test_quota_exhausted_after_max_attempts(): + """After MAX admitted attempts even a correct key must be rejected with 429 (#2392). + + Every admission consumes quota regardless of outcome; the legacy endpoint + accepted every guess independently, exhausting the 24-bit keyspace via bursts. + """ + client, reset_password, _ = await _create_client() + + for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW): + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + assert resp.status_code == 403 + + # The very next request carries the CORRECT key but has no quota left. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 429 + reset_password.assert_not_awaited() + + +async def test_quota_rejects_before_touching_user_lookup(): + """An exhausted quota must reject early, before the sleep and any service calls.""" + client, _, get_user_by_email = await _create_client() + + user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW + user_module._reset_password_state['window_started_at'] = time.monotonic() + + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + + assert resp.status_code == 429 + get_user_by_email.assert_not_awaited() + + +async def test_window_rolls_over_and_admits_again(): + """Once the fixed window elapses, the quota resets and a correct key works again.""" + client, reset_password, _ = await _create_client() + + user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW + user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1 + + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + + assert resp.status_code == 200 + reset_password.assert_awaited_once() + + +async def test_success_does_not_restore_quota(): + """A successful reset does NOT restore quota: brute-force budget survives wins (#2392). + + The legacy clear-on-success let attackers interleave correct-looking states; + success only proves knowledge of the key once, it must not refill attempts. + """ + client, _, _ = await _create_client() + + for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1): + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + assert resp.status_code == 403 + + # Last slot is spent on the genuine reset. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 200 + + # Quota is exhausted; even a correct key waits for the next window. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 429 + + +async def test_concurrent_burst_cannot_bypass_quota(monkeypatch): + """A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression). + + The vulnerable version accounted failures after several awaits, letting all + concurrent requests pass the gate ({403: 20}). Admission is now synchronous + and await-free, so total admissions are capped regardless of scheduling. + """ + + # Swap the AsyncMock sleep for a real cooperative yield so tasks actually + # interleave mid-handler like they do under production load. + async def _yield_sleep(_seconds): + await asyncio.sleep(0) + + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep)) + + client, reset_password, _ = await _create_client() + + responses = await asyncio.gather( + *(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20)) + ) + + status_counts: dict[int, int] = {} + for resp in responses: + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + assert status_counts == {403: 5, 429: 15} + reset_password.assert_not_awaited() diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx index 321127bad..104a76da5 100644 --- a/web/src/app/reset-password/page.tsx +++ b/web/src/app/reset-password/page.tsx @@ -7,12 +7,6 @@ import { CardTitle, CardDescription, } from '@/components/ui/card'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from '@/components/ui/input-otp'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -28,14 +22,12 @@ import { import { useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useNavigate } from 'react-router-dom'; -import { Mail, Lock, ArrowLeft } from 'lucide-react'; +import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ThemeToggle } from '@/components/ui/theme-toggle'; -const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/; - const formSchema = (t: (key: string) => string) => z.object({ email: z.string().email(t('common.invalidEmail')), @@ -136,28 +128,17 @@ export default function ResetPassword() { {t('resetPassword.recoveryKeyDescription')} - { - // 将输入的值转换为大写 - const upperValue = value.toUpperCase(); - field.onChange(upperValue); - }} - > - - - - - - - - - - - - + {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} +
+ + +
diff --git a/web/tests/e2e/reset-password.spec.ts b/web/tests/e2e/reset-password.spec.ts new file mode 100644 index 000000000..96170f386 --- /dev/null +++ b/web/tests/e2e/reset-password.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const resetEndpoint = '**/api/v1/user/reset-password'; +const email = 'reset-password@example.com'; +const newPassword = 'Regression-password-2026!'; +const successMessage = 'Password reset successfully, please login'; +const failureMessage = + 'Password reset failed, please check your email and recovery key'; + +async function fillResetForm(page: Page, recoveryKey: string) { + await page.goto('/reset-password'); + await page.getByPlaceholder('Enter email address').fill(email); + const recoveryInput = page.getByPlaceholder('Enter recovery key'); + await recoveryInput.fill(recoveryKey); + await expect(recoveryInput).toHaveValue(recoveryKey); + await page.getByPlaceholder('Enter new password').fill(newPassword); +} + +test.beforeEach(async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: false }); +}); + +const recoveryKeys = [ + { name: 'eight-character recovery code', value: '2A3B4C5D' }, + { name: 'six-character legacy recovery key', value: 'ABC123' }, + { + name: '43-character mixed-case base64url recovery key', + value: 'aB-_'.repeat(10) + 'xYz', + }, +]; + +for (const { name, value } of recoveryKeys) { + test(`submits the ${name} verbatim and returns to login`, async ({ + page, + }) => { + const requests: { method: string; body: unknown }[] = []; + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await route.fulfill({ + status: 200, + json: { code: 0, msg: 'ok', data: { user: email } }, + }); + }); + + await fillResetForm(page, value); + await page + .getByRole('button', { name: 'Reset Password', exact: true }) + .click(); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText(successMessage, { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Login with password', exact: true }), + ).toBeVisible(); + expect(requests).toEqual([ + { + method: 'POST', + body: { user: email, recovery_key: value, new_password: newPassword }, + }, + ]); + await expect(page.getByText(failureMessage, { exact: true })).toHaveCount( + 0, + ); + }); +} + +test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({ + page, +}) => { + const recoveryKey = '2A3B4C5D'; + const requests: { method: string; body: unknown }[] = []; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await responseGate; + await route.fulfill({ + status: 429, + json: { code: -1, msg: 'Too many attempts, try again later' }, + }); + }); + + await fillResetForm(page, recoveryKey); + const submit = page.locator('button[type="submit"]'); + await submit.click(); + try { + await expect.poll(() => requests.length).toBe(1); + await expect(submit).toBeDisabled(); + await expect(submit).toHaveText('Resetting...'); + } finally { + releaseResponse(); + } + + await expect(page.getByText(failureMessage, { exact: true })).toBeVisible(); + await expect(submit).toBeEnabled(); + await expect(submit).toHaveText('Reset Password'); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0); + await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue( + recoveryKey, + ); + expect(requests).toEqual([ + { + method: 'POST', + body: { + user: email, + recovery_key: recoveryKey, + new_password: newPassword, + }, + }, + ]); +}); From 1fa5e2f7559e1a81bad72552b2c7d8b7da4c582d Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Tue, 8 Sep 2026 13:03:02 +0800 Subject: [PATCH 24/56] feat(api): add system capabilities endpoint --- .../pkg/api/http/controller/groups/system.py | 24 +++++++++ tests/integration/api/test_workspaces.py | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index a8be6ba22..55af6c4bd 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -12,6 +12,21 @@ from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled from .....workspace.invitation_delivery import InvitationDeliveryService +SYSTEM_CAPABILITY_OPERATIONS = ( + 'bot.list', + 'bot.get', + 'bot.create', + 'bot.update', + 'bot.delete', + 'pipeline.list', + 'pipeline.get', + 'pipeline.create', + 'pipeline.update', + 'pipeline.delete', + 'pipeline.copy', +) + + @group.group_class('system', '/api/v1/system') class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: @@ -26,6 +41,15 @@ class SystemRouterGroup(group.RouterGroup): } ) + @self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY) + async def _() -> str: + return self.success( + data={ + 'schema_version': 1, + 'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS}, + } + ) + @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: # Read wizard_status and wizard_progress from metadata table diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 511607f0d..b861ce2da 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import json import logging from types import SimpleNamespace @@ -22,6 +23,7 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata +from langbot.pkg.entity.persistence import apikey from langbot.pkg.entity.persistence.user import User from langbot.pkg.entity.persistence.workspace import ( Workspace, @@ -462,6 +464,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi ) assert invalid_auth.status_code == 401 + invalid_capabilities = await client.get( + '/api/v1/system/capabilities', + headers={'X-API-Key': 'lbk_invalid'}, + ) + assert invalid_capabilities.status_code == 401 + response = await client.get( '/api/v1/system/context', headers={ @@ -478,6 +486,34 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'permissions': [], } + capabilities_response = await client.get( + '/api/v1/system/capabilities', + headers={ + 'X-API-Key': created['key'], + 'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored', + }, + ) + assert capabilities_response.status_code == 200 + capabilities = (await capabilities_response.get_json())['data'] + assert capabilities['schema_version'] == 1 + assert sorted(capabilities['operations']) == sorted( + [ + 'bot.list', + 'bot.get', + 'bot.create', + 'bot.update', + 'bot.delete', + 'pipeline.list', + 'pipeline.get', + 'pipeline.create', + 'pipeline.update', + 'pipeline.delete', + 'pipeline.copy', + ] + ) + assert all(item == {'supported': True} for item in capabilities['operations'].values()) + assert created['key'] not in await capabilities_response.get_data(as_text=True) + bearer_response = await client.get( '/api/v1/system/context', headers={'Authorization': f'Bearer {created["key"]}'}, @@ -491,6 +527,17 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi ) assert jwt_response.status_code == 401 + await application.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey) + .where(apikey.ApiKey.uuid == created['uuid']) + .values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1)) + ) + expired_capabilities = await client.get( + '/api/v1/system/capabilities', + headers={'X-API-Key': created['key']}, + ) + assert expired_capabilities.status_code == 401 + revoke_response = await client.delete( f'/api/v1/apikeys/{created["id"]}', headers=_auth(owner_token, workspace_uuid), @@ -502,6 +549,11 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi headers={'X-API-Key': created['key']}, ) assert revoked_response.status_code == 401 + revoked_capabilities = await client.get( + '/api/v1/system/capabilities', + headers={'X-API-Key': created['key']}, + ) + assert revoked_capabilities.status_code == 401 async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core( From 1a69747a06c11d338c1bc13f1a7a8e8a776ec4be Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Tue, 8 Sep 2026 14:06:13 +0800 Subject: [PATCH 25/56] feat(api): expose task status to api keys --- .../pkg/api/http/controller/groups/system.py | 20 ++- src/langbot/pkg/core/taskmgr.py | 61 +++++++-- tests/integration/api/test_workspaces.py | 82 ++++++++++++ tests/unit_tests/core/test_taskmgr.py | 123 ++++++++++++++++++ 4 files changed, 270 insertions(+), 16 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 55af6c4bd..2c243ea36 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -7,7 +7,7 @@ from .. import group from .....utils import constants from .....entity.persistence.metadata import WorkspaceMetadata from ...authz import Permission -from ...context import RequestContext +from ...context import PrincipalType, RequestContext from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled from .....workspace.invitation_delivery import InvitationDeliveryService @@ -24,6 +24,11 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'pipeline.update', 'pipeline.delete', 'pipeline.copy', + 'task.list', + 'task.get', + 'knowledge_base.get', + 'knowledge_base.file.store', + 'file.document.upload', ) @@ -258,7 +263,7 @@ class SystemRouterGroup(group.RouterGroup): @self.route( '/tasks', methods=['GET'], - auth_type=group.AuthType.USER_TOKEN, + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: @@ -277,18 +282,23 @@ class SystemRouterGroup(group.RouterGroup): instance_uuid=request_context.instance_uuid, workspace_uuid=request_context.workspace_uuid, placement_generation=request_context.placement_generation, + public=request_context.principal.principal_type == PrincipalType.API_KEY, ) ) @self.route( '/tasks/', methods=['GET'], - auth_type=group.AuthType.USER_TOKEN, + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, permission=Permission.RESOURCE_VIEW, ) async def _(task_id: str, request_context: RequestContext) -> str: + try: + task_index = int(task_id) + except (TypeError, ValueError): + return self.http_status(404, 404, 'Task not found') task = self.ap.task_mgr.get_task_by_id( - int(task_id), + task_index, instance_uuid=request_context.instance_uuid, workspace_uuid=request_context.workspace_uuid, placement_generation=request_context.placement_generation, @@ -297,6 +307,8 @@ class SystemRouterGroup(group.RouterGroup): if task is None: return self.http_status(404, 404, 'Task not found') + if request_context.principal.principal_type == PrincipalType.API_KEY: + return self.success(data=task.to_public_dict()) return self.success(data=task.to_dict()) @self.route( diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py index 25cc38e0d..6c5df7b81 100644 --- a/src/langbot/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import typing import datetime import time @@ -197,6 +198,41 @@ class TaskWrapper: }, } + def to_public_dict(self) -> dict: + """Return the stable task projection exposed to API-key callers.""" + if self.task.cancelled(): + status = 'cancelled' + error = {'type': 'task_cancelled', 'message': 'Task was cancelled'} + result = None + elif not self.task.done(): + status = 'running' + error = None + result = None + else: + exception = self.assume_exception() + if exception is not None: + status = 'failed' + error = {'type': 'task_failed', 'message': 'Task execution failed'} + result = None + else: + status = 'succeeded' + error = None + result = self.assume_result() + try: + json.dumps(result) + except (TypeError, ValueError): + result = None + + return { + 'id': self.id, + 'task_type': self.task_type, + 'kind': self.kind, + 'status': status, + 'error': error, + 'result': result, + 'created_at': self.created_at, + } + def cancel(self): self.task.cancel() @@ -325,19 +361,20 @@ class AsyncTaskManager: instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, + public: bool = False, ) -> dict: - return { - 'tasks': [ - t.to_dict() - for t in self.tasks - if (type is None or t.task_type == type) - and (kind is None or t.kind == kind) - and (instance_uuid is None or t.instance_uuid == instance_uuid) - and (workspace_uuid is None or t.workspace_uuid == workspace_uuid) - and (placement_generation is None or t.placement_generation == placement_generation) - ], - 'id_index': TaskWrapper._id_index, - } + tasks = [ + t.to_public_dict() if public else t.to_dict() + for t in self.tasks + if (type is None or t.task_type == type) + and (kind is None or t.kind == kind) + and (instance_uuid is None or t.instance_uuid == instance_uuid) + and (workspace_uuid is None or t.workspace_uuid == workspace_uuid) + and (placement_generation is None or t.placement_generation == placement_generation) + ] + if public: + return {'tasks': tasks} + return {'tasks': tasks, 'id_index': TaskWrapper._id_index} def get_stats(self) -> dict: completed = sum(1 for t in self.tasks if t.task.done()) diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index b861ce2da..656ae5941 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -509,6 +509,11 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'pipeline.update', 'pipeline.delete', 'pipeline.copy', + 'task.list', + 'task.get', + 'knowledge_base.get', + 'knowledge_base.file.store', + 'file.document.upload', ] ) assert all(item == {'supported': True} for item in capabilities['operations'].values()) @@ -556,6 +561,83 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi assert revoked_capabilities.status_code == 401 +async def test_api_key_can_query_tasks_with_public_contract_and_resource_permission(workspace_api): + application, client, _, owner_token = workspace_api + task_query = {} + task_lookup = {} + fake_task = SimpleNamespace( + to_public_dict=lambda: {'id': 7, 'status': 'running', 'error': None, 'result': None}, + to_dict=lambda: {'id': 7, 'runtime': {'state': 'PENDING'}}, + ) + + def get_tasks_dict(*args, **kwargs): + task_query.update(kwargs) + if kwargs.get('public'): + return {'tasks': []} + return {'tasks': [], 'id_index': 1} + + def get_task_by_id(*args, **kwargs): + task_lookup.update(kwargs) + return fake_task if args and args[0] == 7 else None + + application.task_mgr = SimpleNamespace( + get_tasks_dict=get_tasks_dict, + get_task_by_id=get_task_by_id, + ) + current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token)) + workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid'] + create_response = await client.post( + '/api/v1/apikeys', + headers=_auth(owner_token, workspace_uuid), + json={'name': 'Task reader', 'scopes': ['resource.view']}, + ) + assert create_response.status_code == 200 + key = (await create_response.get_json())['data']['key']['key'] + + listing = await client.get('/api/v1/system/tasks', headers={'X-API-Key': key}) + assert listing.status_code == 200 + assert (await listing.get_json())['data'] == {'tasks': []} + assert task_query['instance_uuid'] == application.workspace_service.instance_uuid + assert task_query['workspace_uuid'] == workspace_uuid + assert task_query['placement_generation'] == 1 + assert task_query['public'] is True + + bearer_listing = await client.get('/api/v1/system/tasks', headers=_auth(owner_token, workspace_uuid)) + assert bearer_listing.status_code == 200 + assert (await bearer_listing.get_json())['data'] == {'tasks': [], 'id_index': 1} + + public_task = await client.get('/api/v1/system/tasks/7', headers={'X-API-Key': key}) + assert public_task.status_code == 200 + assert (await public_task.get_json())['data'] == { + 'id': 7, + 'status': 'running', + 'error': None, + 'result': None, + } + assert task_lookup == { + 'instance_uuid': application.workspace_service.instance_uuid, + 'workspace_uuid': workspace_uuid, + 'placement_generation': 1, + } + + legacy_task = await client.get('/api/v1/system/tasks/7', headers=_auth(owner_token, workspace_uuid)) + assert legacy_task.status_code == 200 + assert (await legacy_task.get_json())['data'] == {'id': 7, 'runtime': {'state': 'PENDING'}} + + missing = await client.get('/api/v1/system/tasks/not-an-id', headers={'X-API-Key': key}) + assert missing.status_code == 404 + + no_permission_response = await client.post( + '/api/v1/apikeys', + headers=_auth(owner_token, workspace_uuid), + json={'name': 'Task denied', 'scopes': []}, + ) + assert no_permission_response.status_code == 200 + no_permission_key = (await no_permission_response.get_json())['data']['key']['key'] + denied = await client.get('/api/v1/system/tasks', headers={'X-API-Key': no_permission_key}) + assert denied.status_code == 403 + + async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core( workspace_api, ): diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py index 44503de9a..4c23fb2ac 100644 --- a/tests/unit_tests/core/test_taskmgr.py +++ b/tests/unit_tests/core/test_taskmgr.py @@ -338,6 +338,69 @@ class TestTaskWrapper: assert result['runtime']['exception'] == 'Test error' assert 'exception_traceback' in result['runtime'] + @pytest.mark.asyncio + async def test_public_dict_has_stable_success_projection(self): + _, TaskWrapper, _ = get_taskmgr_classes() + mock_app = create_mock_app() + + async def successful_coro(): + return {'file_id': 'file-a'} + + wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store') + await wrapper.task + + result = wrapper.to_public_dict() + + assert result == { + 'id': wrapper.id, + 'task_type': 'system', + 'kind': 'knowledge_base.store', + 'status': 'succeeded', + 'error': None, + 'result': {'file_id': 'file-a'}, + 'created_at': result['created_at'], + } + assert 'runtime' not in result + assert 'traceback' not in str(result).lower() + + @pytest.mark.asyncio + async def test_public_dict_hides_exception_traceback(self): + _, TaskWrapper, _ = get_taskmgr_classes() + mock_app = create_mock_app() + + async def failing_coro(): + raise ValueError('private failure') + + wrapper = TaskWrapper(mock_app, failing_coro()) + try: + await wrapper.task + except ValueError: + pass + + result = wrapper.to_public_dict() + + assert result['status'] == 'failed' + assert result['error'] == {'type': 'task_failed', 'message': 'Task execution failed'} + assert 'runtime' not in result + assert 'traceback' not in str(result).lower() + + @pytest.mark.asyncio + async def test_public_dict_does_not_change_success_when_result_is_not_json_serializable(self): + _, TaskWrapper, _ = get_taskmgr_classes() + mock_app = create_mock_app() + + async def successful_coro(): + return object() + + wrapper = TaskWrapper(mock_app, successful_coro()) + await wrapper.task + + result = wrapper.to_public_dict() + + assert result['status'] == 'succeeded' + assert result['error'] is None + assert result['result'] is None + @pytest.mark.asyncio async def test_cancel_task(self): """Test cancel method cancels the asyncio task.""" @@ -487,6 +550,66 @@ class TestAsyncTaskManager: w2.cancel() w3.cancel() + @pytest.mark.asyncio + async def test_public_task_queries_keep_workspace_and_generation_isolation(self): + _, _, AsyncTaskManager = get_taskmgr_classes() + mock_app = create_mock_app() + manager = AsyncTaskManager(mock_app) + + async def dummy_coro(): + await asyncio.sleep(10) + + current = manager.create_user_task( + dummy_coro(), + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=2, + ) + other_workspace = manager.create_user_task( + dummy_coro(), + instance_uuid='instance-a', + workspace_uuid='workspace-b', + placement_generation=2, + ) + stale_generation = manager.create_user_task( + dummy_coro(), + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + ) + + result = manager.get_tasks_dict( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=2, + public=True, + ) + + assert [task['id'] for task in result['tasks']] == [current.id] + assert 'id_index' not in result + assert ( + manager.get_task_by_id( + other_workspace.id, + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=2, + ) + is None + ) + assert ( + manager.get_task_by_id( + stale_generation.id, + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=2, + ) + is None + ) + + current.cancel() + other_workspace.cancel() + stale_generation.cancel() + @pytest.mark.asyncio async def test_cancel_by_scope(self): """Test cancel_by_scope cancels matching tasks.""" From e6e8258545f4da8c96f6a20bdf24f295763108e5 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Tue, 8 Sep 2026 17:43:11 +0800 Subject: [PATCH 26/56] feat(api): advertise extension operation capabilities --- src/langbot/pkg/api/http/controller/groups/system.py | 9 +++++++++ tests/integration/api/test_workspaces.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 2c243ea36..97203e47b 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -29,6 +29,15 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'knowledge_base.get', 'knowledge_base.file.store', 'file.document.upload', + 'plugin.install.github', + 'plugin.install.marketplace', + 'plugin.install.local', + 'plugin.upgrade', + 'plugin.get', + 'skill.install.github', + 'skill.install.upload', + 'mcp_server.get', + 'mcp_server.test', ) diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 656ae5941..e41c3322a 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -514,6 +514,15 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'knowledge_base.get', 'knowledge_base.file.store', 'file.document.upload', + 'plugin.install.github', + 'plugin.install.marketplace', + 'plugin.install.local', + 'plugin.upgrade', + 'plugin.get', + 'skill.install.github', + 'skill.install.upload', + 'mcp_server.get', + 'mcp_server.test', ] ) assert all(item == {'supported': True} for item in capabilities['operations'].values()) From 814740ea681e74a8acdecd6a17dc8a318d858142 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Tue, 8 Sep 2026 18:53:55 +0800 Subject: [PATCH 27/56] feat(api): advertise managed read operations --- .../pkg/api/http/controller/groups/system.py | 14 ++++++++++++++ tests/integration/api/test_workspaces.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 97203e47b..12fc0afe3 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -28,15 +28,29 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'task.get', 'knowledge_base.get', 'knowledge_base.file.store', + 'knowledge_base.retrieve', 'file.document.upload', 'plugin.install.github', 'plugin.install.marketplace', 'plugin.install.local', 'plugin.upgrade', 'plugin.get', + 'plugin.list', + 'plugin.config.get', + 'plugin.logs', + 'skill.list', + 'skill.get', + 'skill.files.list', + 'skill.files.read', + 'skill.preview', 'skill.install.github', 'skill.install.upload', + 'mcp_server.list', 'mcp_server.get', + 'mcp_server.resources', + 'mcp_server.resource_templates', + 'mcp_server.resource_read', + 'mcp_server.logs', 'mcp_server.test', ) diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index e41c3322a..2ad6d90c6 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -513,15 +513,29 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'task.get', 'knowledge_base.get', 'knowledge_base.file.store', + 'knowledge_base.retrieve', 'file.document.upload', 'plugin.install.github', 'plugin.install.marketplace', 'plugin.install.local', 'plugin.upgrade', 'plugin.get', + 'plugin.list', + 'plugin.config.get', + 'plugin.logs', + 'skill.list', + 'skill.get', + 'skill.files.list', + 'skill.files.read', + 'skill.preview', 'skill.install.github', 'skill.install.upload', + 'mcp_server.list', 'mcp_server.get', + 'mcp_server.resources', + 'mcp_server.resource_templates', + 'mcp_server.resource_read', + 'mcp_server.logs', 'mcp_server.test', ] ) From 4eea3419e81ef0b2c2840d583e3c8d4492fa8412 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Tue, 8 Sep 2026 23:07:43 +0800 Subject: [PATCH 28/56] feat(api): advertise knowledge and MCP management --- src/langbot/pkg/api/http/controller/groups/system.py | 9 +++++++++ tests/integration/api/test_workspaces.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 12fc0afe3..2d612be50 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -26,8 +26,14 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'pipeline.copy', 'task.list', 'task.get', + 'knowledge_base.list', 'knowledge_base.get', + 'knowledge_base.create', + 'knowledge_base.update', + 'knowledge_base.delete', + 'knowledge_base.file.list', 'knowledge_base.file.store', + 'knowledge_base.file.delete', 'knowledge_base.retrieve', 'file.document.upload', 'plugin.install.github', @@ -47,6 +53,9 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'skill.install.upload', 'mcp_server.list', 'mcp_server.get', + 'mcp_server.create', + 'mcp_server.update', + 'mcp_server.delete', 'mcp_server.resources', 'mcp_server.resource_templates', 'mcp_server.resource_read', diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 2ad6d90c6..b3d798bbf 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -511,8 +511,14 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'pipeline.copy', 'task.list', 'task.get', + 'knowledge_base.list', 'knowledge_base.get', + 'knowledge_base.create', + 'knowledge_base.update', + 'knowledge_base.delete', + 'knowledge_base.file.list', 'knowledge_base.file.store', + 'knowledge_base.file.delete', 'knowledge_base.retrieve', 'file.document.upload', 'plugin.install.github', @@ -532,6 +538,9 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'skill.install.upload', 'mcp_server.list', 'mcp_server.get', + 'mcp_server.create', + 'mcp_server.update', + 'mcp_server.delete', 'mcp_server.resources', 'mcp_server.resource_templates', 'mcp_server.resource_read', From 8281eb18c9155b59046060b84b18d872d52a0e46 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Wed, 9 Sep 2026 00:12:40 +0800 Subject: [PATCH 29/56] feat(api): advertise plugin and skill management --- src/langbot/pkg/api/http/controller/groups/system.py | 6 ++++++ tests/integration/api/test_workspaces.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 2d612be50..3d8bc9dc0 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -43,11 +43,17 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'plugin.get', 'plugin.list', 'plugin.config.get', + 'plugin.config.update', 'plugin.logs', + 'plugin.delete', 'skill.list', 'skill.get', + 'skill.create', + 'skill.update', + 'skill.delete', 'skill.files.list', 'skill.files.read', + 'skill.files.write', 'skill.preview', 'skill.install.github', 'skill.install.upload', diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index b3d798bbf..0421a7365 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -528,11 +528,17 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'plugin.get', 'plugin.list', 'plugin.config.get', + 'plugin.config.update', 'plugin.logs', + 'plugin.delete', 'skill.list', 'skill.get', + 'skill.create', + 'skill.update', + 'skill.delete', 'skill.files.list', 'skill.files.read', + 'skill.files.write', 'skill.preview', 'skill.install.github', 'skill.install.upload', From d8b3dad212a570235ff8d93124f2783235fb284a Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Wed, 9 Sep 2026 10:38:16 +0800 Subject: [PATCH 30/56] feat(api): support explicit provider secret projection --- .../http/controller/groups/provider/models.py | 46 ++++- .../controller/groups/provider/providers.py | 17 +- .../http/controller/groups/provider/query.py | 15 ++ .../api/test_provider_controller_secrets.py | 182 ++++++++++++++++++ 4 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/langbot/pkg/api/http/controller/groups/provider/query.py create mode 100644 tests/unit_tests/api/test_provider_controller_secrets.py diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py index 236000d9f..fed754201 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -3,6 +3,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext from ... import group +from .query import resolve_include_secret @group.group_class('models/llm', '/api/v1/provider/models/llm') @@ -16,7 +17,12 @@ class LLMModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: provider_uuid = quart.request.args.get('provider_uuid') - include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE) + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) if provider_uuid: models = await self.ap.llm_model_service.get_llm_models_by_provider( request_context, @@ -53,10 +59,16 @@ class LLMModelsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(model_uuid: str, request_context: RequestContext) -> str: + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) model = await self.ap.llm_model_service.get_llm_model( request_context, model_uuid, - include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + include_secret=include_secret, ) if model is None: return self.http_status(404, -1, 'model not found') @@ -111,7 +123,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: provider_uuid = quart.request.args.get('provider_uuid') - include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE) + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) if provider_uuid: models = await self.ap.embedding_models_service.get_embedding_models_by_provider( request_context, @@ -148,10 +165,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(model_uuid: str, request_context: RequestContext) -> str: + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) model = await self.ap.embedding_models_service.get_embedding_model( request_context, model_uuid, - include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + include_secret=include_secret, ) if model is None: return self.http_status(404, -1, 'model not found') @@ -208,7 +231,12 @@ class RerankModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: provider_uuid = quart.request.args.get('provider_uuid') - include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE) + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) if provider_uuid: models = await self.ap.rerank_models_service.get_rerank_models_by_provider( request_context, @@ -245,10 +273,16 @@ class RerankModelsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(model_uuid: str, request_context: RequestContext) -> str: + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) model = await self.ap.rerank_models_service.get_rerank_model( request_context, model_uuid, - include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + include_secret=include_secret, ) if model is None: return self.http_status(404, -1, 'model not found') diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py index bf8a195ae..25becae6e 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -3,6 +3,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext from ... import group +from .query import resolve_include_secret @group.group_class('models/providers', '/api/v1/provider/providers') @@ -15,9 +16,15 @@ class ModelProvidersRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) providers = await self.ap.provider_service.get_providers( request_context, - include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + include_secret=include_secret, ) for provider in providers: counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid']) @@ -47,10 +54,16 @@ class ModelProvidersRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(provider_uuid: str, request_context: RequestContext) -> str: + include_secret, error = resolve_include_secret( + quart.request.args.get('include_secret'), + permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + ) + if error: + return self.http_status(400, -1, error) provider = await self.ap.provider_service.get_provider( request_context, provider_uuid, - include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE), + include_secret=include_secret, ) if provider is None: return self.http_status(404, -1, 'provider not found') diff --git a/src/langbot/pkg/api/http/controller/groups/provider/query.py b/src/langbot/pkg/api/http/controller/groups/provider/query.py new file mode 100644 index 000000000..bd1793fe2 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/provider/query.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]: + """Resolve the optional secret projection query parameter.""" + + if raw_value is None: + return permitted, None + + value = raw_value.strip().lower() + if value == 'false': + return False, None + if value == 'true': + return permitted, None + return False, 'include_secret must be either true or false' diff --git a/tests/unit_tests/api/test_provider_controller_secrets.py b/tests/unit_tests/api/test_provider_controller_secrets.py new file mode 100644 index 000000000..78c72ba8e --- /dev/null +++ b/tests/unit_tests/api/test_provider_controller_secrets.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import copy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +from langbot.pkg.api.http.controller.groups.provider.models import ( + EmbeddingModelsRouterGroup, + LLMModelsRouterGroup, + RerankModelsRouterGroup, +) +from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup +from langbot.pkg.api.http.controller.groups.provider.query import resolve_include_secret +from langbot.pkg.api.http.service.secrets import redact_secrets + + +pytestmark = pytest.mark.asyncio + +RAW_PROVIDER = { + 'uuid': 'provider-test', + 'name': 'Test Provider', + 'api_keys': ['provider-secret'], +} +RAW_MODEL = { + 'uuid': 'model-test', + 'name': 'Test Model', + 'extra_args': {'headers': {'Authorization': 'Bearer model-secret'}}, +} + + +def _access(role: str): + return SimpleNamespace( + execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1), + workspace=SimpleNamespace(uuid='workspace-test'), + membership=SimpleNamespace(uuid='membership-test', role=role, projection_revision=1), + ) + + +def _project(value: dict, include_secret: bool) -> dict: + value = copy.deepcopy(value) + return value if include_secret else redact_secrets(value) + + +async def _create_client(role: str): + application = SimpleNamespace() + account = SimpleNamespace(uuid='account-test', user='test@example.com') + application.user_service = SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)) + application.apikey_service = SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None)) + application.workspace_collaboration_service = SimpleNamespace( + resolve_account_workspace=AsyncMock(return_value=_access(role)) + ) + + async def get_providers(_context, *, include_secret=False): + return [_project(RAW_PROVIDER, include_secret)] + + async def get_provider(_context, _uuid, *, include_secret=False): + return _project(RAW_PROVIDER, include_secret) + + application.provider_service = SimpleNamespace( + get_providers=AsyncMock(side_effect=get_providers), + get_provider=AsyncMock(side_effect=get_provider), + get_provider_model_counts=AsyncMock( + return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1} + ), + ) + + def model_service(list_name: str, get_name: str): + async def get_models(_context, *, include_secret=False): + return [_project(RAW_MODEL, include_secret)] + + async def get_model(_context, _uuid, *, include_secret=False): + return _project(RAW_MODEL, include_secret) + + return SimpleNamespace( + **{ + list_name: AsyncMock(side_effect=get_models), + get_name: AsyncMock(side_effect=get_model), + } + ) + + application.llm_model_service = model_service('get_llm_models', 'get_llm_model') + application.embedding_models_service = model_service('get_embedding_models', 'get_embedding_model') + application.rerank_models_service = model_service('get_rerank_models', 'get_rerank_model') + + quart_app = quart.Quart(__name__) + for router_type in ( + ModelProvidersRouterGroup, + LLMModelsRouterGroup, + EmbeddingModelsRouterGroup, + RerankModelsRouterGroup, + ): + await router_type(application, quart_app).initialize() + return application, quart_app.test_client() + + +def _headers() -> dict[str, str]: + return {'Authorization': 'Bearer test-token'} + + +@pytest.mark.parametrize( + ('raw_value', 'permitted', 'expected', 'error'), + [ + (None, True, True, None), + (None, False, False, None), + ('false', True, False, None), + ('true', True, True, None), + ('true', False, False, None), + ('invalid', True, False, 'include_secret must be either true or false'), + ], +) +def test_resolve_include_secret(raw_value, permitted, expected, error): + assert resolve_include_secret(raw_value, permitted=permitted) == (expected, error) + + +@pytest.mark.parametrize( + 'endpoint', + [ + '/api/v1/provider/providers', + '/api/v1/provider/models/llm', + '/api/v1/provider/models/embedding', + '/api/v1/provider/models/rerank', + ], +) +async def test_default_preserves_secrets_and_explicit_false_redacts_high_permission_reads(endpoint): + application, client = await _create_client('developer') + + default_response = await client.get(endpoint, headers=_headers()) + false_response = await client.get(f'{endpoint}?include_secret=false', headers=_headers()) + + assert default_response.status_code == 200 + assert false_response.status_code == 200 + default_data = await default_response.get_json() + false_data = await false_response.get_json() + default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0] + false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0] + assert '***' not in str(default_value) + assert '***' in str(false_value) + + +@pytest.mark.parametrize( + 'endpoint', + [ + '/api/v1/provider/providers', + '/api/v1/provider/models/llm', + '/api/v1/provider/models/embedding', + '/api/v1/provider/models/rerank', + ], +) +async def test_explicit_true_does_not_grant_low_permission_reads(endpoint): + _application, client = await _create_client('viewer') + + response = await client.get(f'{endpoint}?include_secret=true', headers=_headers()) + + assert response.status_code == 200 + data = await response.get_json() + value = data['data'].get('providers', data['data'].get('models'))[0] + assert '***' in str(value) + + +@pytest.mark.parametrize( + 'endpoint', + [ + '/api/v1/provider/providers', + '/api/v1/provider/providers/provider-test', + '/api/v1/provider/models/llm', + '/api/v1/provider/models/llm/model-test', + '/api/v1/provider/models/embedding', + '/api/v1/provider/models/embedding/model-test', + '/api/v1/provider/models/rerank', + '/api/v1/provider/models/rerank/model-test', + ], +) +async def test_invalid_include_secret_returns_bad_request(endpoint): + _application, client = await _create_client('developer') + + response = await client.get(f'{endpoint}?include_secret=maybe', headers=_headers()) + + assert response.status_code == 400 + assert (await response.get_json())['msg'] == 'include_secret must be either true or false' From d90253cc776ce46cbfc2b7b60e8671983f1b8040 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Wed, 9 Sep 2026 11:18:01 +0800 Subject: [PATCH 31/56] feat(api): advertise provider and model management --- .../pkg/api/http/controller/groups/system.py | 24 +++++++++++++++++++ tests/integration/api/test_workspaces.py | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 3d8bc9dc0..57feaecaf 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -46,6 +46,30 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'plugin.config.update', 'plugin.logs', 'plugin.delete', + 'provider.list', + 'provider.get', + 'provider.create', + 'provider.update', + 'provider.delete', + 'provider.scan_models', + 'model.llm.list', + 'model.llm.get', + 'model.llm.create', + 'model.llm.update', + 'model.llm.delete', + 'model.llm.test', + 'model.embedding.list', + 'model.embedding.get', + 'model.embedding.create', + 'model.embedding.update', + 'model.embedding.delete', + 'model.embedding.test', + 'model.rerank.list', + 'model.rerank.get', + 'model.rerank.create', + 'model.rerank.update', + 'model.rerank.delete', + 'model.rerank.test', 'skill.list', 'skill.get', 'skill.create', diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 0421a7365..ed3ca22bd 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -531,6 +531,30 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'plugin.config.update', 'plugin.logs', 'plugin.delete', + 'provider.list', + 'provider.get', + 'provider.create', + 'provider.update', + 'provider.delete', + 'provider.scan_models', + 'model.llm.list', + 'model.llm.get', + 'model.llm.create', + 'model.llm.update', + 'model.llm.delete', + 'model.llm.test', + 'model.embedding.list', + 'model.embedding.get', + 'model.embedding.create', + 'model.embedding.update', + 'model.embedding.delete', + 'model.embedding.test', + 'model.rerank.list', + 'model.rerank.get', + 'model.rerank.create', + 'model.rerank.update', + 'model.rerank.delete', + 'model.rerank.test', 'skill.list', 'skill.get', 'skill.create', From fc1c998434cd945165635a546d6436b633623b88 Mon Sep 17 00:00:00 2001 From: WODE25500 <2550012136@QQ.COM> Date: Wed, 9 Sep 2026 13:51:42 +0800 Subject: [PATCH 32/56] fix: Matrix relogin UnboundLocalError (#2516) Co-authored-by: WODE25500 <318555974+WODE25500@users.noreply.github.com> --- src/langbot/pkg/platform/sources/matrix.py | 4 +- tests/unit_tests/test_matrix_relogin.py | 61 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_matrix_relogin.py diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py index 7cf39dd86..f0a9822e3 100644 --- a/src/langbot/pkg/platform/sources/matrix.py +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') continue - # Use configured logout command, fallback to deriving from login command - logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + # Use configured logout command, fallback to deriving from login command + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') # Cancel existing tasks diff --git a/tests/unit_tests/test_matrix_relogin.py b/tests/unit_tests/test_matrix_relogin.py new file mode 100644 index 000000000..03ec63ff3 --- /dev/null +++ b/tests/unit_tests/test_matrix_relogin.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``). + +The old code placed ``logout_cmd = ...`` on a line after an unconditional +``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id`` +branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned +on the configured path, so any bridge with both ``login_command`` and +``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above +the ``if`` so it runs for configured bridges. + +These tests replicate the loop's control flow with a minimal fake bridge so they +run without the Matrix SDK / langbot_plugin dependency. +""" + + +class FakeBridge: + def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None): + self.user_id = user_id + self.login_command = login_command + self.logout_command = logout_command + self.dm_room_id = dm_room_id + + +def _relogin_commands(bridges: list[FakeBridge]) -> list[str]: + """Return the logout commands the fixed loop would send for each bridge.""" + commands: list[str] = [] + for bridge in bridges: + if not bridge.login_command or not bridge.dm_room_id: + continue + # Use configured logout command, fallback to deriving from login command. + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + commands.append(logout_cmd) + return commands + + +def test_configured_bridge_produces_logout_command_without_error() -> None: + bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')] + # Old code raised UnboundLocalError here; the fix must return the derived + # logout command (no configured logout_command -> derive from login). + assert _relogin_commands(bridges) == ['logout'] + + +def test_configured_logout_command_is_used_verbatim() -> None: + bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')] + assert _relogin_commands(bridges) == ['leave'] + + +def test_skipped_bridge_is_ignored() -> None: + # Missing dm_room_id -> skipped, no command emitted. + bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)] + assert _relogin_commands(bridges) == [] + + +def test_relogin_never_raises_for_mixed_configurations() -> None: + bridges = [ + FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command + FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'), + FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id + ] + assert _relogin_commands(bridges) == ['logout'] From 3a82aa5fccc133d97e6dadfa44e9e8a5cf38c537 Mon Sep 17 00:00:00 2001 From: Tynwink <70106851+Tynwink2000@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:39:49 +0800 Subject: [PATCH 33/56] Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/unit_tests/core/test_taskmgr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py index 4c23fb2ac..3477478bb 100644 --- a/tests/unit_tests/core/test_taskmgr.py +++ b/tests/unit_tests/core/test_taskmgr.py @@ -375,6 +375,7 @@ class TestTaskWrapper: try: await wrapper.task except ValueError: + # Expected failure: task must complete in failed state for public serialization checks. pass result = wrapper.to_public_dict() From 485113ae43db9d35bbec3fe6c892f3735f79cc92 Mon Sep 17 00:00:00 2001 From: WODE25500 <2550012136@QQ.COM> Date: Wed, 9 Sep 2026 15:46:26 +0800 Subject: [PATCH 34/56] fix: default Langflow tweaks to empty object when unset (#2519) Co-authored-by: WODE25500 <318555974+WODE25500@users.noreply.github.com> --- src/langbot/pkg/provider/runners/langflowapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/langbot/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py index 10c66df59..1658eda2e 100644 --- a/src/langbot/pkg/provider/runners/langflowapi.py +++ b/src/langbot/pkg/provider/runners/langflowapi.py @@ -90,7 +90,7 @@ class LangflowAPIRunner(runner.RequestRunner): } # 如果配置中有tweaks,则添加到负载中 - tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks')) + tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks') or '{}') if tweaks: payload['tweaks'] = tweaks From ce6b647fe7e620031c3dd9d16a8e8c08ba13b4b1 Mon Sep 17 00:00:00 2001 From: huige66631 <1030089807@qq.com> Date: Wed, 9 Sep 2026 16:13:33 +0800 Subject: [PATCH 35/56] feat(mcp): detect OAuth-protected remote MCP servers (#2363) * feat(mcp): surface OAuth-required server tests * fix(mcp): show connection failure details in status cards --------- Co-authored-by: RockChinQ --- docs/assets/pr-2363/oauth-required-state.png | Bin 0 -> 75030 bytes src/langbot/pkg/api/http/service/mcp.py | 25 +++-- src/langbot/pkg/provider/tools/loaders/mcp.py | 57 ++++++++++- .../pkg/provider/tools/loaders/mcp_stdio.py | 1 + .../api/service/test_mcp_service.py | 70 ++++++++++++++ .../provider/test_mcp_remote_transport.py | 80 +++++++++++++++- web/playwright.config.ts | 2 +- .../home/mcp/components/mcp-form/MCPForm.tsx | 90 ++++++++++++++---- web/src/app/infra/entities/api/index.ts | 1 + web/src/i18n/locales/en-US.ts | 9 ++ web/src/i18n/locales/es-ES.ts | 9 ++ web/src/i18n/locales/ja-JP.ts | 9 ++ web/src/i18n/locales/ru-RU.ts | 9 ++ web/src/i18n/locales/th-TH.ts | 9 ++ web/src/i18n/locales/vi-VN.ts | 9 ++ web/src/i18n/locales/zh-Hans.ts | 8 ++ web/src/i18n/locales/zh-Hant.ts | 8 ++ web/tests/e2e/mcp-oauth-required.spec.ts | 74 ++++++++++++++ 18 files changed, 433 insertions(+), 37 deletions(-) create mode 100644 docs/assets/pr-2363/oauth-required-state.png create mode 100644 web/tests/e2e/mcp-oauth-required.spec.ts diff --git a/docs/assets/pr-2363/oauth-required-state.png b/docs/assets/pr-2363/oauth-required-state.png new file mode 100644 index 0000000000000000000000000000000000000000..a969c3b9feabb33d6e611bd02fa7b736764dd31c GIT binary patch literal 75030 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 z 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 literal 0 HcmV?d00001 diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 09bc185f7..67e8e14ae 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -446,15 +446,19 @@ class MCPService: persisted_session = runtime_mcp_session async def _refresh_and_report() -> None: - needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None - if needs_start: - await persisted_session.start() - else: - try: - await persisted_session.refresh() - except Exception: + try: + needs_start = ( + persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None + ) + if needs_start: await persisted_session.start() - ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() + else: + try: + await persisted_session.refresh() + except Exception: + await persisted_session.start() + finally: + ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() coroutine = _refresh_and_report() else: @@ -471,8 +475,11 @@ class MCPService: async def _run_and_cleanup() -> None: try: await test_session.start() - ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict() finally: + # start() raises for a failed connection. Preserve the + # terminal runtime state so the UI can render actionable + # failure phases such as OAuth-required. + ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict() try: await test_session.shutdown() except Exception as exc: diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 2084bd154..6cde880a3 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import enum import json import math @@ -206,6 +207,13 @@ class MCPSessionStatus(enum.Enum): ERROR = 'error' +@dataclasses.dataclass(frozen=True) +class MCPOAuthChallenge: + """Bearer challenge metadata returned by an OAuth-protected MCP server.""" + + resource_metadata_url: str | None + + class _TransportReconnect(Exception): """Internal signal: the Box stdio WS transport dropped but the managed process is still alive. Triggers a lightweight transport reconnect that @@ -265,6 +273,7 @@ class RuntimeMCPSession: _ready_event: asyncio.Event error_message: str | None = None + _public_error_code: str = 'runtime_error' error_phase: MCPSessionErrorPhase | None = None @@ -510,6 +519,13 @@ class RuntimeMCPSession: await self._init_streamable_http_server() return except Exception as e: + if self._extract_oauth_challenge(e) is not None: + self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED + self.ap.logger.info( + f'MCP server {self.server_name}: remote server requires OAuth authorization; ' + 'not falling back to SSE' + ) + raise if not self._should_fallback_to_sse(e): self.ap.logger.info( f'MCP server {self.server_name}: Streamable HTTP transport failed ' @@ -630,6 +646,7 @@ class RuntimeMCPSession: except Exception as e: self.status = MCPSessionStatus.ERROR self.error_message = str(e) + self._public_error_code = self._classify_public_error(e) self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}') # Do NOT set _ready_event here — let _lifecycle_loop_with_retry # handle retries first. It will set the event when all retries @@ -752,6 +769,11 @@ class RuntimeMCPSession: except Exception as e: if self._shutdown_event.is_set(): return # Shutdown requested, don't retry + if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED: + self.retry_count = attempt + 1 + self.status = MCPSessionStatus.ERROR + self._ready_event.set() + return if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE: box_service = getattr(self.ap, 'box_service', None) if box_service is not None and getattr(box_service, 'enabled', True): @@ -832,6 +854,39 @@ class RuntimeMCPSession: else: yield exc + @staticmethod + def _classify_public_error(exc: BaseException) -> str: + """Expose a safe category without transport URLs, headers, or arguments.""" + for leaf in RuntimeMCPSession._iter_exception_leaves(exc): + if isinstance(leaf, httpx.HTTPStatusError): + return f'http_{leaf.response.status_code}' + if isinstance(leaf, (httpx.TimeoutException, TimeoutError)): + return 'connection_timeout' + if isinstance(leaf, httpx.ConnectError): + return 'connection_unreachable' + return 'runtime_error' + + @staticmethod + def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None: + """Extract an OAuth Bearer challenge from a remote MCP connection failure.""" + for leaf in RuntimeMCPSession._iter_exception_leaves(exc): + if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401: + continue + for header in leaf.response.headers.get_list('www-authenticate'): + bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE) + if bearer_match is None: + continue + metadata_match = re.search( + r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))', + header[bearer_match.end() :], + flags=re.IGNORECASE, + ) + if metadata_match is None: + continue + resource_metadata_url = metadata_match.group(1) or metadata_match.group(2) + return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url) + return None + @staticmethod def _should_fallback_to_sse(exc: BaseException) -> bool: """Whether a Streamable HTTP failure matches legacy-SSE fallback. @@ -1374,7 +1429,7 @@ class RuntimeMCPSession: # environment values. Detailed diagnostics belong in AUDIT_VIEW # logs; resource-list responses expose only a stable status. 'error_message': 'MCP runtime failed' if self.error_message else None, - 'error_code': 'runtime_error' if self.error_message else None, + 'error_code': self._public_error_code if self.error_message else None, 'error_phase': self.error_phase.value if self.error_phase else None, 'retry_count': self.retry_count, 'tool_count': len(self.get_tools()), diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index 110fff431..48aa9a227 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum): MCP_INIT = 'mcp_init' RUNTIME = 'runtime' TOOL_CALL = 'tool_call' + OAUTH_REQUIRED = 'oauth_required' # Stdio MCP refused because Box is disabled in config or currently # unavailable. Not transient — retries would be pointless. The frontend # uses this phase to render a localized actionable message instead of diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 31c531e98..5cddde1e4 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -1009,6 +1009,37 @@ class TestMCPServiceTestMCPServer: # Verify - returns task ID assert task_id == 123 + @pytest.mark.parametrize('refresh_first', [False, True]) + async def test_persisted_test_preserves_failure_details(self, refresh_first): + from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus + + runtime_info = {'status': 'error', 'error_message': 'HTTP 403: access denied'} + session = SimpleNamespace( + status=MCPSessionStatus.CONNECTED if refresh_first else MCPSessionStatus.ERROR, + session=object(), + refresh=AsyncMock(side_effect=RuntimeError('refresh failed')), + start=AsyncMock(side_effect=RuntimeError('Connection failed, please check URL')), + get_runtime_info_dict=Mock(return_value=runtime_info), + ) + captured = {} + + def create_user_task(coroutine, **kwargs): + captured.update(coroutine=coroutine, context=kwargs['context']) + return SimpleNamespace(id=123) + + ap = SimpleNamespace( + tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=session))), + task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)), + ) + service = _service(ap) + service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'})) + await service.test_mcp_server(_CONTEXT, 'existing-server', {}) + with pytest.raises(RuntimeError, match='Connection failed'): + await captured['coroutine'] + assert captured['context'].metadata['runtime_info'] == runtime_info + session.start.assert_awaited_once() + assert session.refresh.await_count == int(refresh_first) + async def test_test_mcp_server_not_found_raises(self): """Raises ValueError when server not found.""" # Setup @@ -1052,6 +1083,45 @@ class TestMCPServiceTestMCPServer: ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once() assert task_id == 456 + async def test_transient_test_preserves_runtime_info_after_connection_failure(self): + runtime_info = { + 'status': 'error', + 'error_phase': 'oauth_required', + 'retry_count': 1, + } + mock_session = SimpleNamespace( + server_name='oauth-server', + start=AsyncMock(side_effect=RuntimeError('connection failed')), + get_runtime_info_dict=Mock(return_value=runtime_info), + shutdown=AsyncMock(), + ) + ap = SimpleNamespace( + tool_mgr=SimpleNamespace( + mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session)) + ) + ) + captured: dict = {} + + def create_user_task(coroutine, **kwargs): + captured['coroutine'] = coroutine + captured['context'] = kwargs['context'] + return SimpleNamespace(id=457) + + ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)) + service = _service(ap) + + task_id = await service.test_mcp_server( + _CONTEXT, + '_', + {'name': 'OAuth server', 'mode': 'remote', 'enable': True, 'extra_args': {}}, + ) + + assert task_id == 457 + with pytest.raises(RuntimeError, match='connection failed'): + await captured['coroutine'] + assert captured['context'].metadata['runtime_info'] == runtime_info + mock_session.shutdown.assert_awaited_once_with() + async def test_rejected_transient_test_session_is_shut_down(self): ap = SimpleNamespace() mock_session = MagicMock() diff --git a/tests/unit_tests/provider/test_mcp_remote_transport.py b/tests/unit_tests/provider/test_mcp_remote_transport.py index 7abe8afea..91a142134 100644 --- a/tests/unit_tests/provider/test_mcp_remote_transport.py +++ b/tests/unit_tests/provider/test_mcp_remote_transport.py @@ -13,7 +13,15 @@ from aiohttp import web from mcp import types as mcp_types from langbot.pkg.api.http.context import ExecutionContext -from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession +from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus, MCPToolCallTimeoutError, RuntimeMCPSession +from langbot.pkg.provider.tools.loaders.mcp_stdio import MCPSessionErrorPhase + + +TEST_EXECUTION_CONTEXT = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, +) TEST_EXECUTION_CONTEXT = ExecutionContext( @@ -24,8 +32,9 @@ TEST_EXECUTION_CONTEXT = ExecutionContext( class _TransportProbe: - def __init__(self, streamable_status: int | None) -> None: + def __init__(self, streamable_status: int | None, streamable_headers: dict[str, str] | None = None) -> None: self.streamable_status = streamable_status + self.streamable_headers = streamable_headers or {} self.streamable_posts = 0 self.streamable_messages: list[str] = [] self.sse_gets = 0 @@ -93,7 +102,7 @@ class _TransportProbe: } ) return web.Response(status=202) - return web.Response(status=self.streamable_status) + return web.Response(status=self.streamable_status, headers=self.streamable_headers) self.sse_gets += 1 response = web.StreamResponse( @@ -136,8 +145,8 @@ class _TransportProbe: @asynccontextmanager -async def _transport_server(streamable_status: int | None): - probe = _TransportProbe(streamable_status) +async def _transport_server(streamable_status: int | None, streamable_headers: dict[str, str] | None = None): + probe = _TransportProbe(streamable_status, streamable_headers) application = web.Application() application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint) application.router.add_post('/messages', probe.handle_sse_message) @@ -265,6 +274,45 @@ async def test_remote_transport_real_non_compatibility_error_does_not_fallback(s await _close_session(session) +def test_remote_transport_extracts_oauth_resource_metadata_from_bearer_challenge(): + request = httpx.Request('POST', 'https://mcp.example/mcp') + response = httpx.Response( + 401, + headers={ + 'WWW-Authenticate': ( + 'Basic realm="MCP", Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"' + ) + }, + request=request, + ) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + response.raise_for_status() + + challenge = RuntimeMCPSession._extract_oauth_challenge(exc_info.value) + + assert challenge is not None + assert challenge.resource_metadata_url == 'https://mcp.example/.well-known/oauth-protected-resource' + + +@pytest.mark.asyncio +async def test_remote_transport_oauth_challenge_sets_non_retryable_authorization_state(): + headers = { + 'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"' + } + async with _transport_server(401, headers) as (probe, url): + session = _session(url) + + await session._lifecycle_loop_with_retry() + + assert session.status == MCPSessionStatus.ERROR + assert session.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED + assert session.retry_count == 1 + assert session._ready_event.is_set() + assert probe.streamable_posts == 1 + assert probe.sse_gets == 0 + + @pytest.mark.asyncio async def test_remote_transport_real_timeout_does_not_fallback(): async with _transport_server(None) as (probe, url): @@ -313,3 +361,25 @@ async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fa finally: probe.release_streamable_request.set() await _close_session(session) + + +@pytest.mark.parametrize( + ('error', 'expected'), + [ + (httpx.ConnectError('secret host'), 'connection_unreachable'), + (httpx.ReadTimeout('secret URL'), 'connection_timeout'), + (TimeoutError('secret command'), 'connection_timeout'), + (RuntimeError('secret environment'), 'runtime_error'), + ( + httpx.HTTPStatusError( + 'secret response', + request=httpx.Request('POST', 'https://example.test/?token=secret'), + response=httpx.Response(403), + ), + 'http_403', + ), + ], +) +def test_public_error_category_does_not_expose_exception_details(error, expected): + grouped = ExceptionGroup('secret outer exception', [error]) + assert RuntimeMCPSession._classify_public_error(grouped) == expected diff --git a/web/playwright.config.ts b/web/playwright.config.ts index e15c6ef9e..90990a759 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ }, ], webServer: { - command: 'pnpm exec vite --host 127.0.0.1 --port 4173', + command: 'corepack pnpm@8.9.2 exec vite --host 127.0.0.1 --port 4173', url: 'http://127.0.0.1:4173', reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index d1e4140a4..d7f71261a 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -8,7 +8,14 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react'; +import { + Braces, + Loader2, + ShieldAlert, + Trash2, + Wrench, + XCircle, +} from 'lucide-react'; import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -101,7 +108,7 @@ function StatusDisplay({
- {t('mcp.connectionFailed')} + {t('mcp.connectionFailedStatus')}
@@ -117,15 +124,41 @@ function StatusDisplay({ ); } + if (runtimeInfo.error_phase === 'oauth_required') { + return ( +
+
+ + + {t('mcp.oauthAuthorizationRequired')} + +
+
+ {t('mcp.oauthAuthorizationRequiredSuggestion')} +
+
+ ); + } + + const httpStatus = runtimeInfo.error_code?.match(/^http_(\d{3})$/)?.[1]; + const errorDetail = + runtimeInfo.error_code === 'connection_unreachable' + ? t('mcp.connectionUnreachable') + : runtimeInfo.error_code === 'connection_timeout' + ? t('mcp.connectionTimeout') + : httpStatus + ? t('mcp.connectionHttpError', { status: httpStatus }) + : runtimeInfo.error_message || t('mcp.unknownError'); + return (
- {t('mcp.connectionFailed')} + {t('mcp.connectionFailedStatus')}
- {runtimeInfo.error_message && ( -
- {runtimeInfo.error_message} + {errorDetail && ( +
+ {errorDetail}
)}
@@ -835,15 +868,31 @@ const MCPForm = forwardRef(function MCPForm( async function testMcp() { setMcpTesting(true); + const showConnectionFailure = ( + message: string, + info?: MCPServerRuntimeInfo, + ) => { + toast.error(t('mcp.connectionFailedStatus')); + setRuntimeInfo({ + tool_count: 0, + tools: [], + resource_count: 0, + resources: [], + ...info, + status: MCPSessionStatus.ERROR, + error_message: info?.error_message || message, + }); + }; + try { const mode = form.getValues('mode'); if (mode === 'stdio' && !mcpStdioEnabled) { - toast.error(t('mcp.stdioDisabledByPolicy')); + showConnectionFailure(t('mcp.stdioDisabledByPolicy')); setMcpTesting(false); return; } if (mode === 'stdio' && !boxAvailable) { - toast.error(t('mcp.stdioBlockedByBoxToast')); + showConnectionFailure(t('mcp.stdioBlockedByBoxToast')); setMcpTesting(false); return; } @@ -914,15 +963,9 @@ const MCPForm = forwardRef(function MCPForm( if (taskResp.runtime.exception) { const errorMsg = taskResp.runtime.exception || t('mcp.unknownError'); - toast.error(`${t('mcp.testError')}: ${errorMsg}`); - setRuntimeInfo({ - status: MCPSessionStatus.ERROR, - error_message: errorMsg, - tool_count: 0, - tools: [], - resource_count: 0, - resources: [], - }); + const runtimeInfoFromTest = taskResp.task_context?.metadata + ?.runtime_info as MCPServerRuntimeInfo | undefined; + showConnectionFailure(errorMsg, runtimeInfoFromTest); if (shouldTestPersistedServer) { await onPersistedTestComplete?.(serverName); } @@ -949,14 +992,19 @@ const MCPForm = forwardRef(function MCPForm( clearInterval(interval); setMcpTesting(false); const errorMsg = - (err as CustomApiError).msg || t('mcp.getTaskFailed'); - toast.error(`${t('mcp.testError')}: ${errorMsg}`); + (err as CustomApiError).msg || + (err as Error).message || + t('mcp.getTaskFailed'); + showConnectionFailure(errorMsg); } }, 1000); } catch (err) { setMcpTesting(false); - const errorMsg = (err as Error).message || t('mcp.unknownError'); - toast.error(`${t('mcp.testError')}: ${errorMsg}`); + const errorMsg = + (err as CustomApiError).msg || + (err as Error).message || + t('mcp.unknownError'); + showConnectionFailure(errorMsg); } } diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 7c66f6be1..997ffc489 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -586,6 +586,7 @@ export enum MCPSessionStatus { } export interface MCPServerRuntimeInfo { + error_code?: string; status: MCPSessionStatus; error_message?: string; /** Stage at which the session failed. Frontends key off this to render diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 3fb93b85a..e5192ecad 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -872,6 +872,15 @@ const enUS = { connectionSuccess: 'Connection successful', connectionFailed: 'Connection failed, please check URL', connectionFailedStatus: 'Connection Failed', + connectionUnreachable: + 'Cannot reach the MCP server. Check that it is running and accessible.', + connectionTimeout: + 'The MCP server did not respond in time. Check the service or increase the timeout.', + connectionHttpError: + 'The MCP server returned HTTP {{status}}. Check its access requirements and server logs.', + oauthAuthorizationRequired: 'OAuth authorization required', + oauthAuthorizationRequiredSuggestion: + 'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.', boxDisabledStdioRefused: 'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 54918de07..9f8f97d99 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -892,6 +892,15 @@ const esES = { connectionSuccess: 'Conexión exitosa', connectionFailed: 'Error de conexión, por favor verifica la URL', connectionFailedStatus: 'Conexión fallida', + connectionUnreachable: + 'No se puede acceder al servidor MCP. Compruebe que esté iniciado y accesible.', + connectionTimeout: + 'El servidor MCP no respondió a tiempo. Compruebe el servicio o aumente el tiempo de espera.', + connectionHttpError: + 'El servidor MCP devolvió HTTP {{status}}. Compruebe los requisitos de acceso y los registros del servidor.', + oauthAuthorizationRequired: 'Se requiere autorización OAuth', + oauthAuthorizationRequiredSuggestion: + 'Este servidor MCP requiere inicio de sesión con OAuth. Aún no está disponible; agregue manualmente un encabezado Authorization si el servidor lo permite.', boxDisabledStdioRefused: 'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7a9803d7a..37fe6f6b2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -880,6 +880,15 @@ const jaJP = { connectionSuccess: '接続に成功しました', connectionFailed: '接続に失敗しました,URLを確認してください', connectionFailedStatus: '接続失敗', + connectionUnreachable: + 'MCP サーバーに接続できません。起動状態とネットワークを確認してください。', + connectionTimeout: + 'MCP サーバーの応答がタイムアウトしました。サービスを確認するか、待機時間を延長してください。', + connectionHttpError: + 'MCP サーバーが HTTP {{status}} を返しました。アクセス要件とサーバーログを確認してください。', + oauthAuthorizationRequired: 'OAuth 認可が必要です', + oauthAuthorizationRequiredSuggestion: + 'この MCP サーバーには OAuth ログインが必要です。現在は OAuth ログインに対応していません。サーバーが許可している場合は、Authorization ヘッダーを手動で追加してください。', boxDisabledStdioRefused: 'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 22d8f04f3..261c8c9e3 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -885,6 +885,15 @@ const ruRU = { connectionSuccess: 'Подключение успешно', connectionFailed: 'Не удалось подключиться, проверьте URL', connectionFailedStatus: 'Ошибка подключения', + connectionUnreachable: + 'Сервер MCP недоступен. Проверьте, запущен ли он и доступен ли по сети.', + connectionTimeout: + 'Время ожидания ответа MCP истекло. Проверьте сервис или увеличьте тайм-аут.', + connectionHttpError: + 'Сервер MCP вернул HTTP {{status}}. Проверьте требования доступа и журналы сервера.', + oauthAuthorizationRequired: 'Требуется авторизация OAuth', + oauthAuthorizationRequiredSuggestion: + 'Для этого MCP-сервера требуется вход через OAuth. OAuth-вход пока не поддерживается; если сервер это позволяет, добавьте заголовок Authorization вручную.', boxDisabledStdioRefused: 'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 44b01f883..2621d770a 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -863,6 +863,15 @@ const thTH = { connectionSuccess: 'เชื่อมต่อสำเร็จ', connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL', connectionFailedStatus: 'เชื่อมต่อล้มเหลว', + connectionUnreachable: + 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ MCP ได้ โปรดตรวจสอบว่าบริการทำงานและเข้าถึงได้', + connectionTimeout: + 'เซิร์ฟเวอร์ MCP ไม่ตอบกลับภายในเวลาที่กำหนด โปรดตรวจสอบบริการหรือเพิ่มเวลารอ', + connectionHttpError: + 'เซิร์ฟเวอร์ MCP ส่งคืน HTTP {{status}} โปรดตรวจสอบข้อกำหนดการเข้าถึงและบันทึกของเซิร์ฟเวอร์', + oauthAuthorizationRequired: 'ต้องมีการอนุญาต OAuth', + oauthAuthorizationRequiredSuggestion: + 'MCP server นี้ต้องเข้าสู่ระบบด้วย OAuth ซึ่งยังไม่รองรับในขณะนี้ หาก server อนุญาต คุณสามารถเพิ่ม Authorization header ด้วยตนเองได้', boxDisabledStdioRefused: 'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index ad7b483e8..6e81af16f 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -878,6 +878,15 @@ const viVN = { connectionSuccess: 'Kết nối thành công', connectionFailed: 'Kết nối thất bại, vui lòng kiểm tra URL', connectionFailedStatus: 'Kết nối thất bại', + connectionUnreachable: + 'Không thể kết nối tới máy chủ MCP. Hãy kiểm tra dịch vụ và kết nối mạng.', + connectionTimeout: + 'Máy chủ MCP không phản hồi kịp thời. Hãy kiểm tra dịch vụ hoặc tăng thời gian chờ.', + connectionHttpError: + 'Máy chủ MCP trả về HTTP {{status}}. Hãy kiểm tra yêu cầu truy cập và nhật ký máy chủ.', + oauthAuthorizationRequired: 'Yêu cầu ủy quyền OAuth', + oauthAuthorizationRequiredSuggestion: + 'MCP server này yêu cầu đăng nhập OAuth. Hiện chưa hỗ trợ đăng nhập OAuth; hãy thêm thủ công tiêu đề Authorization nếu server cho phép.', boxDisabledStdioRefused: 'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 84b7775a0..e91090f4d 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -837,6 +837,14 @@ const zhHans = { connectionSuccess: '连接成功', connectionFailed: '连接失败,请检查URL', connectionFailedStatus: '连接失败', + connectionUnreachable: + '无法连接到 MCP 服务器,请确认服务已启动且网络可达。', + connectionTimeout: 'MCP 服务器响应超时,请检查服务状态或增加超时时间。', + connectionHttpError: + 'MCP 服务器返回 HTTP {{status}},请检查访问要求和服务器日志。', + oauthAuthorizationRequired: '需要 OAuth 授权', + oauthAuthorizationRequiredSuggestion: + '此 MCP 服务器需要 OAuth 登录。当前尚不支持 OAuth 登录;如果服务器允许,可以手动添加 Authorization 请求头。', boxDisabledStdioRefused: 'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。', boxUnavailableStdioRefused: diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 52f30996b..1920a8ba4 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -839,6 +839,14 @@ const zhHant = { connectionSuccess: '連接成功', connectionFailed: '連接失敗,請檢查URL', connectionFailedStatus: '連接失敗', + connectionUnreachable: + '無法連接到 MCP 伺服器,請確認服務已啟動且網路可達。', + connectionTimeout: 'MCP 伺服器回應逾時,請檢查服務狀態或增加逾時時間。', + connectionHttpError: + 'MCP 伺服器回傳 HTTP {{status}},請檢查存取要求和伺服器日誌。', + oauthAuthorizationRequired: '需要 OAuth 授權', + oauthAuthorizationRequiredSuggestion: + '此 MCP 伺服器需要 OAuth 登入。目前尚不支援 OAuth 登入;如果伺服器允許,可以手動新增 Authorization 請求標頭。', boxDisabledStdioRefused: 'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。', boxUnavailableStdioRefused: diff --git a/web/tests/e2e/mcp-oauth-required.spec.ts b/web/tests/e2e/mcp-oauth-required.spec.ts new file mode 100644 index 000000000..61dc5fa5c --- /dev/null +++ b/web/tests/e2e/mcp-oauth-required.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +function ok(data: unknown) { + return { + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }; +} + +test('shows an actionable OAuth-required state after a transient MCP test', async ({ + page, +}, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.route('**/api/v1/mcp/servers/_/test', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(ok({ task_id: 2363 })), + }); + }); + await page.route('**/api/v1/system/tasks/2363', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + ok({ + runtime: { + done: true, + exception: 'Connection failed', + state: 'error', + }, + task_context: { + current_action: 'Testing MCP server', + log: '', + metadata: { + runtime_info: { + status: 'error', + error_phase: 'oauth_required', + retry_count: 1, + tool_count: 0, + tools: [], + resource_count: 0, + resources: [], + }, + }, + }, + }), + ), + }); + }); + + await page.goto('/home/mcp?id=new'); + await page.locator('input[name="name"]').fill('oauth-protected-mcp'); + await page + .locator('input[name="url"]') + .fill('https://mcp.example.test/protected'); + await page.getByRole('button', { name: /^Test$/ }).click(); + + await expect(page.getByText('OAuth authorization required')).toBeVisible(); + await expect( + page.getByText( + 'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.', + ), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('oauth-required.png'), + fullPage: true, + }); +}); From ff6ad6adc207c3b8152766397710daa7002e28ad Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 11 Sep 2026 14:34:37 +0800 Subject: [PATCH 36/56] fix(monitoring): restore Cloud messages and bot-scoped sessions (#2526) * fix(monitoring): restore Cloud message persistence and bot-scoped sessions * fix(migrations): support partial monitoring schemas and align regression fixtures * test(migrations): complete raw bot session fixture values --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .github/workflows/test-migrations.yml | 6 + .../api/http/controller/groups/monitoring.py | 10 + .../pkg/api/http/service/monitoring.py | 83 +- .../api/http/service/monitoring_traffic.py | 83 ++ .../pkg/entity/persistence/monitoring.py | 2 +- .../versions/0023_bot_scoped_sessions.py | 104 ++ src/langbot/pkg/persistence/tenant_uow.py | 2 + src/langbot/pkg/pipeline/monitoring_helper.py | 1 + tests/integration/api/test_monitoring.py | 15 +- .../persistence/resource_migration_support.py | 16 + .../persistence/test_migrations.py | 13 +- .../persistence/test_monitoring_postgres.py | 354 +++++++ .../test_resource_tenancy_migration.py | 8 +- .../service/test_monitoring_identifiers.py | 19 + .../api/service/test_monitoring_sessions.py | 220 ++++ .../api/service/test_monitoring_traffic.py | 125 +++ .../unit_tests/persistence/test_tenant_uow.py | 2 + .../bot-session/BotSessionMonitor.tsx | 86 +- .../overview-cards/OverviewCards.tsx | 14 +- .../overview-cards/TrafficChart.tsx | 141 +-- .../monitoring/hooks/useMonitoringData.ts | 48 +- web/src/app/home/monitoring/page.tsx | 936 ++++++++++-------- .../app/home/monitoring/types/monitoring.ts | 5 + .../monitoring/utils/conversationTurns.ts | 47 +- web/src/app/infra/http/BackendClient.ts | 20 + web/src/i18n/locales/en-US.ts | 9 + web/src/i18n/locales/es-ES.ts | 10 + web/src/i18n/locales/ja-JP.ts | 10 + web/src/i18n/locales/ru-RU.ts | 9 + web/src/i18n/locales/th-TH.ts | 10 + web/src/i18n/locales/vi-VN.ts | 10 + web/src/i18n/locales/zh-Hans.ts | 9 + web/src/i18n/locales/zh-Hant.ts | 9 + .../e2e/bot-session-tool-timeline.spec.ts | 404 ++++++++ web/tests/e2e/monitoring-turns.spec.ts | 193 +++- .../unit/session-monitor-pagination.test.mjs | 20 +- 36 files changed, 2436 insertions(+), 617 deletions(-) create mode 100644 src/langbot/pkg/api/http/service/monitoring_traffic.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0023_bot_scoped_sessions.py create mode 100644 tests/integration/persistence/test_monitoring_postgres.py create mode 100644 tests/unit_tests/api/service/test_monitoring_identifiers.py create mode 100644 tests/unit_tests/api/service/test_monitoring_sessions.py create mode 100644 tests/unit_tests/api/service/test_monitoring_traffic.py diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 086a29915..811939454 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -10,12 +10,16 @@ on: - 'src/langbot/pkg/persistence/**' - 'src/langbot/pkg/entity/persistence/**' - 'tests/integration/persistence/**' + - 'tests/unit_tests/api/service/test_monitoring_sessions.py' + - '.github/workflows/test-migrations.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - 'src/langbot/pkg/persistence/**' - 'src/langbot/pkg/entity/persistence/**' - 'tests/integration/persistence/**' + - 'tests/unit_tests/api/service/test_monitoring_sessions.py' + - '.github/workflows/test-migrations.yml' jobs: test-migrations-sqlite: @@ -80,6 +84,8 @@ jobs: run: >- uv run pytest tests/integration/persistence/test_migrations_postgres.py + tests/integration/persistence/test_monitoring_postgres.py + tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts tests/integration/persistence/test_pgvector_postgres.py tests/integration/persistence/test_release_migration_postgres.py tests/integration/persistence/test_plugin_identity_migration.py diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 9a468ab3f..854ac0ec0 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -5,6 +5,7 @@ import quart from ...authz import Permission from ...context import RequestContext +from ...service.monitoring_traffic import get_traffic_series from .. import group @@ -377,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup): return self.success( data={ + 'traffic': await get_traffic_series( + self.ap, + request_context, + bot_ids=bot_ids or None, + pipeline_ids=pipeline_ids or None, + start_time=start_time, + end_time=end_time, + ), 'overview': overview, 'messages': messages, 'llmCalls': llm_calls, @@ -405,6 +414,7 @@ class MonitoringRouterGroup(group.RouterGroup): session_id, start_time=start_time, end_time=end_time, + bot_id=quart.request.args.get('botId'), ) # Always return success with the analysis data diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index 474a5c1d5..c90cec6d1 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -29,6 +29,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4 _HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100 +def _normalize_user_id(value: str | int | None) -> str | None: + """Convert numeric platform IDs before binding a VARCHAR with asyncpg. + + Opaque string IDs (including whitespace and leading zeros) and missing + IDs must remain unchanged. Do not silently stringify unsupported objects. + """ + if value is None or isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + raise TypeError('user_id must be a string, integer, or None') + + def _workspace_transaction(method): """Run an explicit service entrypoint in one Workspace transaction.""" @@ -281,19 +294,21 @@ class MonitoringService: for _batch_number in range(max_batches): async def delete_batch() -> tuple[int, int]: + key_columns = list(model_cls.__table__.primary_key.columns) select_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(pk_column) + sqlalchemy.select(*key_columns) .where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff) .limit(batch_size) ) - pk_values = list(select_result.scalars().all()) + pk_values = [tuple(row) for row in select_result.all()] if not pk_values: return 0, 0 delete_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(model_cls).where( model_cls.workspace_uuid == workspace_uuid, - pk_column.in_(pk_values), + sqlalchemy.tuple_(*key_columns).in_(pk_values), + ts_column < cutoff, ) ) return len(pk_values), int(delete_result.rowcount or 0) @@ -415,7 +430,7 @@ class MonitoringService: status: str = 'success', level: str = 'info', platform: str | None = None, - user_id: str | None = None, + user_id: str | int | None = None, user_name: str | None = None, runner_name: str | None = None, variables: str | None = None, @@ -437,7 +452,7 @@ class MonitoringService: 'status': status, 'level': level, 'platform': platform, - 'user_id': user_id, + 'user_id': _normalize_user_id(user_id), 'user_name': user_name, 'runner_name': runner_name, 'variables': variables, @@ -610,7 +625,7 @@ class MonitoringService: pipeline_id: str, pipeline_name: str, platform: str | None = None, - user_id: str | None = None, + user_id: str | int | None = None, user_name: str | None = None, ) -> None: """Record a new session""" @@ -622,17 +637,29 @@ class MonitoringService: 'bot_name': bot_name, 'pipeline_id': pipeline_id, 'pipeline_name': pipeline_name, - 'message_count': 0, + 'message_count': 1, 'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'is_active': True, 'platform': platform, - 'user_id': user_id, + 'user_id': _normalize_user_id(user_id), 'user_name': user_name, } + model = persistence_monitoring.MonitoringSession + dialect = self.ap.persistence_mgr.get_db_engine().dialect.name + insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert + statement = insert(model).values(session_data) await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data) + statement.on_conflict_do_update( + index_elements=['workspace_uuid', 'bot_id', 'session_id'], + set_={ + 'message_count': model.message_count + 1, + 'last_activity': statement.excluded.last_activity, + 'pipeline_id': statement.excluded.pipeline_id, + 'pipeline_name': statement.excluded.pipeline_name, + }, + ) ) @_workspace_transaction @@ -642,6 +669,7 @@ class MonitoringService: session_id: str, pipeline_id: str | None = None, pipeline_name: str | None = None, + bot_id: str | None = None, ) -> bool: """Update session last activity time and increment message count. @@ -651,6 +679,9 @@ class MonitoringService: True if session was found and updated, False if session doesn't exist. """ workspace_uuid = self._require_write_context(context) + bot_id = bot_id if bot_id is not None else context.bot_uuid + if not bot_id: + raise ValueError('Session activity requires a bot_id') update_values = { 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'message_count': persistence_monitoring.MonitoringSession.message_count + 1, @@ -667,6 +698,7 @@ class MonitoringService: .where( persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringSession.session_id == session_id, + persistence_monitoring.MonitoringSession.bot_id == bot_id, ) .values(update_values) ) @@ -769,13 +801,13 @@ class MonitoringService: message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) - session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time) if end_time: message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) - session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time) # Total messages message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)) @@ -1272,9 +1304,9 @@ class MonitoringService: if pipeline_ids: conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) if start_time: - conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time) if end_time: - conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time) if user_query and user_query.strip(): user_pattern = f'%{user_query.strip()}%' conditions.append( @@ -1376,6 +1408,7 @@ class MonitoringService: session_id: str, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + bot_id: str | None = None, ) -> dict: """Get bounded session details with full statistics computed in SQL.""" workspace_uuid = require_workspace_uuid(context) @@ -1385,8 +1418,13 @@ class MonitoringService: persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringSession.session_id == session_id, ) - session_result = await self.ap.persistence_mgr.execute_async(session_query) - session_row = session_result.first() + if bot_id is not None: + session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id) + session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2)) + session_rows = session_result.all() + if len(session_rows) > 1: + return {'session_id': session_id, 'found': False, 'ambiguous': True} + session_row = session_rows[0] if session_rows else None if not session_row: return { @@ -1395,6 +1433,7 @@ class MonitoringService: } session = session_row[0] if isinstance(session_row, tuple) else session_row + bot_id = session.bot_id message_stats_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select( @@ -1422,6 +1461,7 @@ class MonitoringService: ).where( persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringMessage.session_id == session_id, + persistence_monitoring.MonitoringMessage.bot_id == bot_id, ) ) message_stats = message_stats_result.one() @@ -1460,6 +1500,7 @@ class MonitoringService: ).where( persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringLLMCall.session_id == session_id, + persistence_monitoring.MonitoringLLMCall.bot_id == bot_id, ) ) llm_stats = llm_stats_result.one() @@ -1486,12 +1527,14 @@ class MonitoringService: ).where( persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringToolCall.session_id == session_id, + persistence_monitoring.MonitoringToolCall.bot_id == bot_id, ) ) tool_stats = tool_stats_result.one() tool_conditions = [ persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringToolCall.session_id == session_id, + persistence_monitoring.MonitoringToolCall.bot_id == bot_id, ] if start_time is not None: tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) @@ -1520,6 +1563,7 @@ class MonitoringService: .where( persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid, persistence_monitoring.MonitoringError.session_id == session_id, + persistence_monitoring.MonitoringError.bot_id == bot_id, ) .order_by(persistence_monitoring.MonitoringError.timestamp.desc()) .limit(detail_limit + 1) @@ -2004,9 +2048,9 @@ class MonitoringService: if pipeline_ids: conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) if start_time: - conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time) if end_time: - conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time) query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( persistence_monitoring.MonitoringSession.last_activity.desc() @@ -2040,6 +2084,7 @@ class MonitoringService: # ========== Feedback Methods ========== + @_workspace_transaction async def record_feedback( self, context: ExecutionContext, @@ -2054,7 +2099,7 @@ class MonitoringService: session_id: str | None = None, message_id: str | None = None, stream_id: str | None = None, - user_id: str | None = None, + user_id: str | int | None = None, platform: str | None = None, ) -> str | None: """Record user feedback (like/dislike) from AI Bot conversation. @@ -2110,7 +2155,7 @@ class MonitoringService: 'session_id': session_id, 'message_id': message_id, 'stream_id': stream_id, - 'user_id': user_id, + 'user_id': _normalize_user_id(user_id), 'platform': platform, } dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name diff --git a/src/langbot/pkg/api/http/service/monitoring_traffic.py b/src/langbot/pkg/api/http/service/monitoring_traffic.py new file mode 100644 index 000000000..702d34e01 --- /dev/null +++ b/src/langbot/pkg/api/http/service/monitoring_traffic.py @@ -0,0 +1,83 @@ +"""Bounded traffic aggregation, independent of record-list pagination.""" + +from __future__ import annotations + +import datetime +import typing + +import sqlalchemy + +from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage +from .tenant import TenantContext, require_workspace_uuid + +if typing.TYPE_CHECKING: + from ....core.app import Application + +MAX_TRAFFIC_POINTS = 1000 + + +async def get_traffic_series( + ap: Application, + context: TenantContext, + *, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, +) -> dict: + """Count all matching records in UTC buckets, returning at most 1000 points.""" + workspace_uuid = require_workspace_uuid(context) + bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day' + step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1) + postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql' + points: dict[datetime.datetime, dict[str, int]] = {} + truncated = False + for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')): + timestamp = model.timestamp + if postgres: + time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp) + else: + pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00' + time_bucket = sqlalchemy.func.strftime(pattern, timestamp) + conditions = [model.workspace_uuid == workspace_uuid] + if bot_ids: + conditions.append(model.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(model.pipeline_id.in_(pipeline_ids)) + if start_time is not None: + conditions.append(timestamp >= start_time) + if end_time is not None: + conditions.append(timestamp <= end_time) + statement = ( + sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count')) + .where(*conditions) + .group_by(time_bucket) + .order_by(time_bucket) + .limit(MAX_TRAFFIC_POINTS + 1) + ) + result = await ap.persistence_mgr.execute_async(statement) + rows = result.all() + truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS + for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]: + key = ( + datetime.datetime.fromisoformat(timestamp_value) + if isinstance(timestamp_value, str) + else timestamp_value + ) + points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count) + + def floor(value: datetime.datetime) -> datetime.datetime: + return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {})) + + first = floor(start_time) if start_time is not None else min(points, default=None) + last = floor(end_time) if end_time is not None else max(points, default=None) + series = [] + if first is not None and last is not None: + cursor = first + while cursor <= last and len(series) < MAX_TRAFFIC_POINTS: + series.append( + {'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})} + ) + cursor += step + truncated = truncated or cursor <= last + return {'bucket': bucket, 'points': series, 'truncated': truncated} diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py index 35ebe161a..1d2e8ed83 100644 --- a/src/langbot/pkg/entity/persistence/monitoring.py +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -111,8 +111,8 @@ class MonitoringSession(Base): sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'), primary_key=True, ) + bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True) session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) - bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) diff --git a/src/langbot/pkg/persistence/alembic/versions/0023_bot_scoped_sessions.py b/src/langbot/pkg/persistence/alembic/versions/0023_bot_scoped_sessions.py new file mode 100644 index 000000000..4e8a03bfe --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0023_bot_scoped_sessions.py @@ -0,0 +1,104 @@ +"""Scope monitoring sessions by bot without changing runtime session IDs. + +Revision ID: 0023_bot_scoped_sessions +Revises: 0022_codex_credentials +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +revision = '0023_bot_scoped_sessions' +down_revision = '0022_codex_credentials' +branch_labels = None +depends_on = None + +_TABLE = 'monitoring_sessions' +_KEY = ['workspace_uuid', 'bot_id', 'session_id'] + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if _TABLE not in inspector.get_table_names(): + return + pk = inspector.get_pk_constraint(_TABLE) + if pk['constrained_columns'] == _KEY: + return + # PostgreSQL alters in place, retaining indexes, grants, policies and RLS. + # SQLite batch reflection retains all existing indexes and foreign keys. + with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch: + batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary') + batch.create_primary_key(f'pk_{_TABLE}', _KEY) + + metadata = sa.MetaData() + sessions = sa.Table(_TABLE, metadata, autoload_with=conn) + messages = sa.Table('monitoring_messages', metadata, autoload_with=conn) + m = messages.c + collisions = ( + sa.select(m.workspace_uuid, m.session_id) + .group_by(m.workspace_uuid, m.session_id) + .having(sa.func.count(sa.distinct(m.bot_id)) > 1) + .subquery() + ) + partition = [m.workspace_uuid, m.bot_id, m.session_id] + # Repair only demonstrable collisions. Retention may have removed earlier + # evidence; these summaries describe surviving messages, never invented text. + ranked = ( + sa.select( + *[m[name] for name in _KEY], + m.bot_name, + m.pipeline_id, + m.pipeline_name, + m.platform, + m.user_id, + m.user_name, + sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0)) + .over(partition_by=partition) + .label('message_count'), + sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'), + sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'), + sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'), + ) + .join( + collisions, + sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id), + ) + .subquery() + ) + columns = _KEY + [ + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'platform', + 'user_id', + 'user_name', + 'message_count', + 'start_time', + 'last_activity', + 'is_active', + ] + select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1) + insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert + statement = insert(sessions).from_select(columns, select) + conn.execute( + statement.on_conflict_do_update( + index_elements=_KEY, + set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'}, + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + if _TABLE not in sa.inspect(conn).get_table_names(): + return + collisions = conn.execute( + sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1') + ).first() + if collisions: + raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records') + pk = sa.inspect(conn).get_pk_constraint(_TABLE) + with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch: + batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary') + batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id']) diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index b361b1067..d28397f60 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -207,6 +207,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None] _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = { 'coalesce': sqlalchemy.sql.functions.coalesce, 'count': sqlalchemy.sql.functions.count, + 'min': sqlalchemy.sql.functions.min, + 'max': sqlalchemy.sql.functions.max, 'now': sqlalchemy.sql.functions.now, 'sum': sqlalchemy.sql.functions.sum, } diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index 0728b0c83..1bab4bda4 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -79,6 +79,7 @@ class MonitoringHelper: session_updated = await ap.monitoring_service.update_session_activity( get_query_execution_context(query), session_id, + bot_id=bot_id, pipeline_id=pipeline_id, pipeline_name=pipeline_name, ) diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index cf4608e65..0422d25e2 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q from __future__ import annotations import pytest -from unittest.mock import MagicMock, AsyncMock, Mock +from unittest.mock import MagicMock, AsyncMock, Mock, patch from types import SimpleNamespace from tests.factories import FakeApp @@ -280,13 +280,20 @@ class TestMonitoringAllDataEndpoint: @pytest.mark.asyncio async def test_get_all_data_success(self, quart_test_client): """GET /api/v1/monitoring/data returns all data.""" - response = await quart_test_client.get( - '/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'} - ) + traffic = {'series': [], 'truncated': False} + with patch( + 'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series', + new=AsyncMock(return_value=traffic), + ) as get_traffic: + response = await quart_test_client.get( + '/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'} + ) + get_traffic.assert_awaited_once() assert response.status_code == 200 data = await response.get_json() assert 'overview' in data['data'] + assert data['data']['traffic'] == traffic @pytest.mark.usefixtures('mock_circular_import_chain') diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py index afb05ae15..c82742acf 100644 --- a/tests/integration/persistence/resource_migration_support.py +++ b/tests/integration/persistence/resource_migration_support.py @@ -193,6 +193,22 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None: sa.Column('message_id', sa.String(255), nullable=True), ) + # Include historical monitoring columns consumed by later migrations. + for table_name in ('monitoring_messages', 'monitoring_sessions'): + table = monitoring_tables[table_name] + for name, value in (('bot_name', 'bot'), ('pipeline_id', 'pipeline-1'), ('pipeline_name', 'pipeline')): + table.append_column(sa.Column(name, sa.String(255), nullable=False, default=value)) + for name in ('platform', 'user_id', 'user_name'): + table.append_column(sa.Column(name, sa.String(255))) + if table_name == 'monitoring_messages': + table.append_column(sa.Column('bot_id', sa.String(255), nullable=False, default='bot-1')) + table.append_column(sa.Column('role', sa.String(50))) + else: + table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1)) + table.append_column( + sa.Column('start_time', sa.DateTime, nullable=False, default=datetime.datetime(2026, 1, 1)) + ) + now = datetime.datetime(2026, 1, 1) async with engine.begin() as conn: await conn.run_sync(metadata.create_all) diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 1c1674a4b..9304ade87 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -17,6 +17,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.persistence import mgr as persistence_mgr # noqa: F401 -- register all ORM tables from langbot.pkg.persistence.alembic_runner import ( run_alembic_downgrade, run_alembic_upgrade, @@ -108,7 +109,6 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0022_codex_credentials' @pytest.mark.asyncio async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): @@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') await run_alembic_upgrade(sqlite_engine, 'head') - assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials' + assert await get_alembic_current(sqlite_engine) == _get_script_head() @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): @@ -280,6 +280,15 @@ class TestSQLiteMigrationUpgrade: class TestSQLiteMigrationFreshDatabase: """Tests for fresh database workflow.""" + @pytest.mark.asyncio + async def test_bot_scoped_sessions_skips_absent_table(self, sqlite_engine): + """A partial schema needs no session key migration in either direction.""" + await run_alembic_stamp(sqlite_engine, '0022_codex_credentials') + await run_alembic_upgrade(sqlite_engine, '0023_bot_scoped_sessions') + assert await get_alembic_current(sqlite_engine) == '0023_bot_scoped_sessions' + await run_alembic_downgrade(sqlite_engine, '0022_codex_credentials') + assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials' + @pytest.mark.asyncio async def test_fresh_db_upgrade_from_scratch(self, tmp_path): """ diff --git a/tests/integration/persistence/test_monitoring_postgres.py b/tests/integration/persistence/test_monitoring_postgres.py new file mode 100644 index 000000000..ebfee7a7e --- /dev/null +++ b/tests/integration/persistence/test_monitoring_postgres.py @@ -0,0 +1,354 @@ +"""Monitoring regressions through asyncpg, Cloud UoW guards, and migrated RLS. + +TEST_POSTGRES_URL must identify a disposable PostgreSQL/pgvector test server +with permission to create databases and roles. Each run owns a fresh database; +no existing tables are dropped. Without that URL these tests are skipped. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from types import SimpleNamespace + +import pytest +import pytest_asyncio +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.entity.persistence import monitoring as models +from langbot.pkg.entity.persistence.workspace import Workspace +from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode +from langbot.pkg.persistence.tenant_uow import TenantScopeRequiredError +from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper + +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio(loop_scope='module')] + +WORKSPACE_A = '00000000-0000-0000-0000-00000000000a' +WORKSPACE_B = '00000000-0000-0000-0000-00000000000b' +RESOURCE = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='same-pipeline', pipeline_name='Pipeline') +MONITORING_TABLES = tuple( + table for table in models.MonitoringMessage.metadata.sorted_tables if table.name.startswith('monitoring_') +) + + +def _context(workspace_uuid): + return ExecutionContext( + instance_uuid='monitoring-postgres-test', + workspace_uuid=workspace_uuid, + placement_generation=1, + bot_uuid=RESOURCE['bot_id'], + pipeline_uuid=RESOURCE['pipeline_id'], + ) + + +def _application(url): + return SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'database': { + 'use': 'postgresql', + 'postgresql': { + 'host': url.host, + 'port': url.port, + 'user': url.username, + 'password': url.password, + 'database': url.database, + }, + } + } + ), + logger=logging.getLogger('monitoring-postgres-test'), + ) + + +@pytest_asyncio.fixture(scope='module', loop_scope='module') +async def cloud_database(): + url = os.environ.get('TEST_POSTGRES_URL') + if not url: + pytest.skip('TEST_POSTGRES_URL not set') + admin_url = sa.engine.make_url(url) + admin = create_async_engine(admin_url, isolation_level='AUTOCOMMIT') + suffix = uuid.uuid4().hex[:12] + database_name = f'lb_monitoring_{suffix}' + runtime_role = f'lb_monitoring_{suffix}' + password = f'Test{uuid.uuid4().hex}' + database_created = role_created = False + release_manager = runtime_manager = None + quote = admin.dialect.identifier_preparer.quote + from langbot.pkg.persistence import mgr as mgr_module + from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager + from langbot.pkg.utils import constants + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(mgr_module.database, 'preregistered_managers', [PostgreSQLDatabaseManager]) + patch.setattr(constants, 'instance_id', 'monitoring-postgres-test') + try: + async with admin.connect() as conn: + await conn.execute(sa.text(f'CREATE DATABASE {quote(database_name)}')) + database_created = True + await conn.execute( + sa.text(f"CREATE ROLE {quote(runtime_role)} LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '{password}'") + ) + role_created = True + release_app = _application(admin_url.set(database=database_name)) + release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION) + release_app.persistence_mgr = release_manager + await release_manager.initialize() + async with release_manager.get_db_engine().begin() as conn: + for workspace in (WORKSPACE_A, WORKSPACE_B): + await conn.execute( + sa.insert(Workspace).values( + uuid=workspace, + instance_uuid='monitoring-postgres-test', + name=workspace, + slug=workspace, + source='cloud_projection', + ) + ) + tables = release_manager._runtime_business_table_names() + quoted_tables = ', '.join(f'public.{quote(name)}' for name in tables) + await conn.execute( + sa.text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}') + ) + await conn.execute(sa.text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}')) + await conn.execute( + sa.text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}') + ) + await conn.execute(sa.text(f'GRANT SELECT ON public.alembic_version TO {quote(runtime_role)}')) + sequences = await release_manager._runtime_business_sequence_names(conn, tables) + if sequences: + names = ', '.join(f'public.{quote(name)}' for name in sequences) + await conn.execute(sa.text(f'GRANT USAGE, SELECT ON SEQUENCE {names} TO {quote(runtime_role)}')) + runtime_app = _application(admin_url.set(database=database_name, username=runtime_role, password=password)) + runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME) + runtime_app.persistence_mgr = runtime_manager + await runtime_manager.initialize() + runtime_app.monitoring_service = MonitoringService(runtime_app) + yield runtime_app, release_manager.get_db_engine() + finally: + if runtime_manager is not None: + await runtime_manager.shutdown() + if release_manager is not None: + await release_manager.shutdown() + async with admin.connect() as conn: + if database_created: + await conn.execute(sa.text(f'DROP DATABASE {quote(database_name)} WITH (FORCE)')) + if role_created: + await conn.execute(sa.text(f'DROP ROLE {quote(runtime_role)}')) + await admin.dispose() + + +@pytest_asyncio.fixture(loop_scope='module') +async def service(cloud_database): + application, admin = cloud_database + async with admin.begin() as conn: + for table in MONITORING_TABLES: + await conn.execute(sa.delete(table)) + application.instance_config.data.pop('monitoring', None) + return application.monitoring_service + + +async def _read(service, method, context, *args, **kwargs): + # HTTP auth binds a tenant scope; exercise that same guard for service reads. + async with service.ap.persistence_mgr.tenant_scope(context.workspace_uuid): + return await getattr(service, method)(context, *args, **kwargs) + + +def _query(context, sender_id): + return SimpleNamespace( + _execution_context=context, + launcher_type='person', + launcher_id='same-user', + sender_id=sender_id, + message_chain=SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'hello'}]), + resp_message_chain=[SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'reply'}])], + message_event=SimpleNamespace(sender=SimpleNamespace(nickname='Alice')), + variables={'public': 'value', '_private': 'hidden'}, + ) + + +@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 ']) +@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback']) +async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type): + context = _context(WORKSPACE_A) + expected = str(user_id) if isinstance(user_id, int) else user_id + if record_type == 'message': + record_id = await service.record_message( + context, + **RESOURCE, + message_content='hello', + session_id='same-session', + user_id=user_id, + ) + details = await _read(service, 'get_message_details', context, record_id) + assert details['message']['user_id'] == expected + elif record_type == 'session': + await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id) + rows, total = await _read(service, 'get_sessions', context) + assert total == 1 + assert rows[0]['user_id'] == expected + else: + await service.record_feedback(context, feedback_id='same-feedback', feedback_type=1, user_id=user_id) + rows, total = await _read(service, 'get_feedback_list', context) + assert total == 1 + assert rows[0]['user_id'] == expected + + +@pytest.mark.parametrize('user_id', [123456789, -100123456789]) +async def test_query_lifecycle_persists_messages_session_and_llm_link(service, user_id, caplog): + context = _context(WORKSPACE_A) + query = _query(context, user_id) + message_id = await MonitoringHelper.record_query_start(service.ap, query, **RESOURCE) + assert message_id, caplog.text + await MonitoringHelper.record_llm_call( + service.ap, + query, + **RESOURCE, + model_name='model', + input_tokens=3, + output_tokens=5, + duration_ms=25, + message_id=message_id, + ) + await MonitoringHelper.record_query_success(service.ap, message_id, query) + await MonitoringHelper.record_query_response(service.ap, query, **RESOURCE) + rows, total = await _read(service, 'get_messages', context) + assert total == 2 + assert {row['role'] for row in rows} == {'user', 'assistant'} + assert {row['user_id'] for row in rows} == {str(user_id)} + details = await _read(service, 'get_message_details', context, message_id) + assert details['message']['status'] == 'success' + assert details['message']['variables'] == '{"public": "value"}' + assert details['llm_calls'][0]['message_id'] == message_id + assert details['llm_stats']['total_tokens'] == 8 + sessions, total = await _read(service, 'get_sessions', context) + assert total == 1 + assert sessions[0]['session_id'] == 'person_same-user' + assert sessions[0]['user_id'] == str(user_id) + assert not [record for record in caplog.records if record.levelno >= logging.ERROR] + + +@pytest.mark.parametrize('user_id', [123, -123]) +async def test_query_error_persists_error_message_and_linked_log(service, user_id, caplog): + context = _context(WORKSPACE_A) + message_id = await MonitoringHelper.record_query_error( + service.ap, + _query(context, user_id), + **RESOURCE, + error=ValueError('failed query'), + ) + assert message_id, caplog.text + details = await _read(service, 'get_message_details', context, message_id) + assert details['message']['user_id'] == str(user_id) + assert details['message']['status'] == 'error' + assert details['errors'][0]['message_id'] == message_id + assert details['errors'][0]['error_type'] == 'ValueError' + + +@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']]) +@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback']) +async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type): + context = _context(WORKSPACE_A) + with pytest.raises(TypeError, match='user_id must be a string, integer, or None'): + if record_type == 'message': + await service.record_message( + context, + **RESOURCE, + message_content='hello', + session_id='session', + user_id=user_id, + ) + elif record_type == 'session': + await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id) + else: + await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id) + async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A): + for model in (models.MonitoringMessage, models.MonitoringSession, models.MonitoringFeedback): + count = await service.ap.persistence_mgr.execute_async(sa.select(sa.func.count()).select_from(model)) + assert count.scalar_one() == 0 + + +async def test_session_analysis_aggregates_under_cloud_sql_guard(service): + context = _context(WORKSPACE_A) + await service.record_session_start(context, **RESOURCE, session_id='same-session') + await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello') + result = await _read(service, 'get_session_analysis', context, 'same-session') + assert result['found'] is True + assert result['message_stats'] == {'total': 1, 'success': 1, 'error': 0, 'pending': 0} + assert result['llm_stats']['total_calls'] == 0 + assert result['tool_stats']['total_calls'] == 0 + assert result['session_duration_seconds'] == 0 + + +async def test_rls_is_enforced_without_application_workspace_predicates(service, cloud_database): + _, admin = cloud_database + for workspace in (WORKSPACE_A, WORKSPACE_B): + await service.record_message( + _context(workspace), **RESOURCE, session_id='same-session', message_content=workspace + ) + async with admin.connect() as conn: + states = ( + await conn.execute( + sa.text( + 'SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class ' + "WHERE relname LIKE 'monitoring_%' AND relkind = 'r'" + ) + ) + ).all() + assert len(states) == len(MONITORING_TABLES) + assert all(enabled and forced for _, enabled, forced in states) + engine = service.ap.persistence_mgr.get_db_engine() + async with engine.connect() as conn: + role = ( + await conn.execute(sa.text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user')) + ).one() + assert role == (False, False) + assert (await conn.execute(sa.select(models.MonitoringMessage.id))).all() == [] + for workspace in (WORKSPACE_A, WORKSPACE_B): + async with service.ap.persistence_mgr.tenant_uow(workspace): + rows = ( + await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.workspace_uuid)) + ).all() + assert rows == [(workspace,)] + with pytest.raises(TenantScopeRequiredError): + await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.id)) + + +async def test_traffic_series_aggregates_all_rows_under_cloud_rls(service): + import datetime + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + context = _context(WORKSPACE_A) + for workspace, count in ((WORKSPACE_A, 61), (WORKSPACE_B, 2)): + async with service.ap.persistence_mgr.tenant_scope(workspace): + await service.ap.persistence_mgr.execute_async( + sa.insert(models.MonitoringMessage).values( + [ + dict( + workspace_uuid=workspace, + id=f'{workspace}-m-{i}', + **RESOURCE, + session_id='shared', + message_content='test', + status='success', + level='info', + timestamp=datetime.datetime(2026, 9, 11, 1, 30), + ) + for i in range(count) + ] + ) + ) + async with service.ap.persistence_mgr.tenant_uow(WORKSPACE_A): + result = await get_traffic_series( + service.ap, + context, + bot_ids=[RESOURCE['bot_id']], + start_time=datetime.datetime(2026, 9, 11), + end_time=datetime.datetime(2026, 9, 12), + ) + assert result['truncated'] is False + assert sum(point['messages'] for point in result['points']) == 61 diff --git a/tests/integration/persistence/test_resource_tenancy_migration.py b/tests/integration/persistence/test_resource_tenancy_migration.py index 8bc3797a1..932a78070 100644 --- a/tests/integration/persistence/test_resource_tenancy_migration.py +++ b/tests/integration/persistence/test_resource_tenancy_migration.py @@ -142,7 +142,7 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path): assert pk_columns == { 'binary_storages': ('workspace_uuid', 'unique_key'), 'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'), - 'monitoring_sessions': ('workspace_uuid', 'session_id'), + 'monitoring_sessions': ('workspace_uuid', 'bot_id', 'session_id'), } pipeline_run_foreign_keys = await _inspect( @@ -237,8 +237,10 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac await conn.execute( sa.text( 'INSERT INTO monitoring_sessions ' - '(workspace_uuid, session_id, bot_id, last_activity, is_active) ' - "VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)" + '(workspace_uuid, session_id, bot_id, bot_name, pipeline_id, pipeline_name, ' + 'start_time, last_activity, message_count, is_active) ' + "VALUES (:workspace_uuid, 'session-1', 'bot-2', 'bot', 'pipeline-2', 'pipeline', " + 'CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 1)' ), {'workspace_uuid': second_workspace_uuid}, ) diff --git a/tests/unit_tests/api/service/test_monitoring_identifiers.py b/tests/unit_tests/api/service/test_monitoring_identifiers.py new file mode 100644 index 000000000..c8a3b7792 --- /dev/null +++ b/tests/unit_tests/api/service/test_monitoring_identifiers.py @@ -0,0 +1,19 @@ +"""Identifier normalization must not rely on SQLite's permissive codecs.""" + +import pytest + +from langbot.pkg.api.http.service import monitoring + + +@pytest.mark.parametrize( + ('value', 'expected'), + [(None, None), ('', ''), ('00123', '00123'), (' 用户 ', ' 用户 '), (123, '123'), (-123, '-123'), (0, '0')], +) +def test_normalize_user_id_preserves_opaque_strings(value, expected): + assert monitoring._normalize_user_id(value) == expected + + +@pytest.mark.parametrize('value', [True, False, 1.5, b'123', ['123'], {'id': 123}]) +def test_normalize_user_id_rejects_unsupported_types(value): + with pytest.raises(TypeError, match='user_id must be a string, integer, or None'): + monitoring._normalize_user_id(value) diff --git a/tests/unit_tests/api/service/test_monitoring_sessions.py b/tests/unit_tests/api/service/test_monitoring_sessions.py new file mode 100644 index 000000000..7451c9124 --- /dev/null +++ b/tests/unit_tests/api/service/test_monitoring_sessions.py @@ -0,0 +1,220 @@ +"""Bot-scoped session regressions exercised against real SQL databases.""" + +import datetime as dt +import logging +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence import monitoring as models +from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper + +from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401 + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.asyncio(loop_scope='module') +async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811 + import asyncio + import importlib + from alembic.migration import MigrationContext + from alembic.operations import Operations + from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read + + ap, admin = cloud_database + service = ap.monitoring_service + ctx = _context(WORKSPACE_A) + await service.record_session_start(ctx, session_id='person_42', **resource('a')) + for bot in ['a', 'b']: + await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot)) + async with admin.begin() as conn: + + def migrate(connection): + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions') + with Operations.context(MigrationContext.configure(connection)): + migration.downgrade() + migration.upgrade() + rls = connection.execute( + sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'") + ).one() + assert tuple(rls) == (True, True) + assert ( + connection.execute( + sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'") + ).scalar_one() + == 1 + ) + + await conn.run_sync(migrate) + rows, total = await _read(service, 'get_sessions', ctx) + assert total == 2 + assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1} + await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)]) + result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a') + assert result['session']['message_count'] == 10 + assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found'] + assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1 + + +async def test_migration_reconstructs_collisions_and_preserves_indexes(service): + import importlib + from alembic.migration import MigrationContext + from alembic.operations import Operations + + engine = service.ap.persistence_mgr.get_db_engine() + async with engine.begin() as conn: + + def upgrade(connection): + table = models.MonitoringSession.__table__ + table.drop(connection) + metadata = sa.MetaData() + legacy = table.to_metadata(metadata) + legacy.primary_key._columns.remove(legacy.c.bot_id) + legacy.c.bot_id.primary_key = False + # Resolve the unchanged Workspace FK in copied metadata. + Base.metadata.tables['workspaces'].to_metadata(metadata) + legacy.create(connection) + now = dt.datetime(2026, 1, 1) + connection.execute( + sa.insert(legacy).values( + workspace_uuid='workspace', + session_id='person_42', + **resource('a'), + message_count=99, + start_time=now, + last_activity=now, + is_active=True, + ) + ) + for bot in ['a', 'b']: + connection.execute( + sa.insert(models.MonitoringMessage).values( + id=bot, + workspace_uuid='workspace', + timestamp=now, + **resource(bot), + session_id='person_42', + message_content=bot, + role='user', + status='success', + level='info', + ) + ) + indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')} + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions') + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + migration.upgrade() # Fresh/already-upgraded schema is safe. + assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [ + 'workspace_uuid', + 'bot_id', + 'session_id', + ] + assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')} + + await conn.run_sync(upgrade) + rows, total = await service.get_sessions(context()) + assert total == 2 + assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1} + assert {r['pipeline_id'] for r in rows} == {'a', 'b'} + + +def context(bot=None): + return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot) + + +def resource(bot): + return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot) + + +@pytest.fixture +async def service(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + class Persistence: + serialize_model = PersistenceManager.serialize_model + + def get_db_engine(self): + return engine + + async def execute_async(self, stmt): + async with engine.begin() as conn: + return await conn.execute(stmt) + + ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__)) + ap.monitoring_service = MonitoringService(ap) + yield ap.monitoring_service + await engine.dispose() + + +async def test_helper_first_message_count_and_two_bot_isolation(service): + for bot in ['a', 'b', 'a']: + query = SimpleNamespace( + _execution_context=context(bot), + launcher_type='person', + launcher_id=42, + sender_id=42, + message_chain=SimpleNamespace(model_dump=lambda: []), + ) + assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot)) + rows, total = await service.get_sessions(context()) + assert total == 2 + assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1} + assert {r['pipeline_id'] for r in rows} == {'a', 'b'} + assert {r['session_id'] for r in rows} == {'person_42'} + + +async def test_analysis_fails_closed_and_scopes_statistics(service): + for bot in ['a', 'b']: + await service.record_session_start(context(bot), session_id='person_42', **resource(bot)) + await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot)) + assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False + result = await service.get_session_analysis(context(), 'person_42', bot_id='b') + assert result['message_stats']['total'] == 1 + assert result['session']['bot_id'] == 'b' + + +async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service): + for _ in range(2): + await service.record_session_start(context('a'), session_id='person_42', **resource('a')) + with pytest.raises(ValueError, match='bot'): + await service.update_session_activity(context(), 'person_42') + assert await service.update_session_activity(context('a'), 'person_42') + assert not await service.update_session_activity(context('b'), 'person_42') + rows, _ = await service.get_sessions(context()) + assert rows[0]['message_count'] == 3 + + +async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service): + for bot in ['a', 'b']: + await service.record_session_start(context(bot), session_id='person_42', **resource(bot)) + old = dt.datetime(2000, 1, 1) + await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old)) + await service.ap.persistence_mgr.execute_async( + sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old) + ) + since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1) + rows, total = await service.get_sessions(context(), start_time=since) + assert total == 1 and rows[0]['bot_id'] == 'b' + assert len(await service.export_sessions(context(), start_time=since)) == 1 + count = await service._delete_expired_in_batches( + context(), + models.MonitoringSession, + models.MonitoringSession.last_activity, + models.MonitoringSession.session_id, + since, + 1, + 2, + ) + assert count == 1 + rows, total = await service.get_sessions(context()) + assert total == 1 and rows[0]['bot_id'] == 'b' diff --git a/tests/unit_tests/api/service/test_monitoring_traffic.py b/tests/unit_tests/api/service/test_monitoring_traffic.py new file mode 100644 index 000000000..527c8bea8 --- /dev/null +++ b/tests/unit_tests/api/service/test_monitoring_traffic.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import datetime +from types import SimpleNamespace + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage +from langbot.pkg.entity.persistence.workspace import Workspace + +pytestmark = pytest.mark.asyncio + +A = '00000000-0000-0000-0000-00000000000a' +B = '00000000-0000-0000-0000-00000000000b' +START = datetime.datetime(2026, 1, 1) + + +@pytest.fixture +async def traffic_app(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute( + sqlalchemy.insert(Workspace), + [ + {'uuid': wid, 'instance_uuid': 'instance', 'name': wid, 'slug': wid, 'source': 'cloud_projection'} + for wid in (A, B) + ], + ) + for wid, bot, count in [(A, 'bot-a', 60), (A, 'bot-b', 7), (B, 'bot-a', 9)]: + common = { + 'workspace_uuid': wid, + 'timestamp': START, + 'bot_id': bot, + 'bot_name': bot, + 'pipeline_id': 'pipeline', + 'pipeline_name': 'Pipeline', + 'session_id': 'person_42', + 'status': 'success', + } + await connection.execute( + sqlalchemy.insert(MonitoringMessage), + [ + dict(common, id=f'{wid}-{bot}-{i}', message_content='test fixture', level='info', role='user') + for i in range(count) + ], + ) + await connection.execute( + sqlalchemy.insert(MonitoringLLMCall), + [ + dict( + common, + id=f'{wid}-{bot}-{i}', + model_name='fixture-model', + input_tokens=1, + output_tokens=1, + total_tokens=2, + duration=1, + ) + for i in range(count) + ], + ) + + class Persistence: + def get_db_engine(self): + return engine + + async def execute_async(self, statement): + async with engine.connect() as connection: + return await connection.execute(statement) + + yield SimpleNamespace(persistence_mgr=Persistence()) + await engine.dispose() + + +async def test_traffic_counts_all_rows_not_just_latest_page(traffic_app): + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1) + result = await get_traffic_series( + traffic_app, context, bot_ids=['bot-a'], start_time=START, end_time=START + datetime.timedelta(hours=2) + ) + assert result['bucket'] == 'hour' + assert result['truncated'] is False + assert sum(point['messages'] for point in result['points']) == 60 + assert sum(point['llm_calls'] for point in result['points']) == 60 + assert len(result['points']) == 3 + assert result['points'][1]['messages'] == result['points'][1]['llm_calls'] == 0 + assert result['points'][0]['timestamp'] == '2026-01-01T00:00:00Z' + + +async def test_traffic_workspace_pipeline_and_empty_filters(traffic_app): + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + context = ExecutionContext(instance_uuid='instance', workspace_uuid=B, placement_generation=1) + kwargs = dict(start_time=START, end_time=START + datetime.timedelta(hours=2)) + result = await get_traffic_series(traffic_app, context, **kwargs) + assert sum(point['messages'] for point in result['points']) == 9 + empty = await get_traffic_series(traffic_app, context, pipeline_ids=['missing'], **kwargs) + assert sum(point['messages'] for point in empty['points']) == 0 + assert sum(point['llm_calls'] for point in empty['points']) == 0 + + +async def test_traffic_bounds_large_ranges_and_marks_truncation(traffic_app): + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1) + result = await get_traffic_series( + traffic_app, context, start_time=START, end_time=START + datetime.timedelta(days=5000) + ) + assert result['bucket'] == 'day' + assert result['truncated'] is True + assert len(result['points']) == 1000 + + +async def test_traffic_fails_closed_without_workspace(traffic_app): + from langbot.pkg.api.http.authz import WorkspaceRequiredError + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + with pytest.raises(WorkspaceRequiredError): + await get_traffic_series(traffic_app, None) diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py index 8fd5e79f8..0c374f201 100644 --- a/tests/unit_tests/persistence/test_tenant_uow.py +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -958,6 +958,8 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql( [ sa.select(sa.literal('set_config(')), sa.select(sa.func.count()), + sa.select(sa.func.min(sa.column('timestamp'))), + sa.select(sa.func.max(sa.column('timestamp'))), sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))), sa.select( sa.func.now(), diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 5b31e2e9b..2505c6e6b 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -157,6 +157,9 @@ const BotSessionMonitor = forwardRef< const [messagePage, setMessagePage] = useState(0); const [loadingSessions, setLoadingSessions] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); + const [sessionError, setSessionError] = useState(false); + const [messageError, setMessageError] = useState(false); + const [analysisError, setAnalysisError] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false); const [feedbackMap, setFeedbackMap] = useState< Record @@ -236,6 +239,8 @@ const BotSessionMonitor = forwardRef< const loadSessions = useCallback(async () => { const requestId = ++sessionRequestIdRef.current; setLoadingSessions(true); + setSessionError(false); + setSessions([]); try { const response = await httpClient.getBotSessions(botId, { limit: SESSION_PAGE_SIZE, @@ -254,6 +259,7 @@ const BotSessionMonitor = forwardRef< } catch (error) { if (requestId === sessionRequestIdRef.current) { console.error('Failed to load sessions:', error); + setSessionError(true); } } finally { if (requestId === sessionRequestIdRef.current) { @@ -274,12 +280,18 @@ const BotSessionMonitor = forwardRef< async (sessionId: string, page: number) => { const requestId = ++messageRequestIdRef.current; setLoadingMessages(true); + setMessageError(false); + setAnalysisError(false); + setMessages([]); + setToolCalls([]); + setFeedbackMap({}); setExpandedToolCallIds({}); try { const messagesRes = await httpClient.getSessionMessages( sessionId, MESSAGE_PAGE_SIZE, page * MESSAGE_PAGE_SIZE, + botId, ); if (requestId !== messageRequestIdRef.current) return; const sorted = (messagesRes.messages ?? []).sort( @@ -290,22 +302,19 @@ const BotSessionMonitor = forwardRef< setMessageTotal(messagesRes.total ?? 0); try { - const analysisParams = new URLSearchParams(); - if (sorted.length > 0) { - analysisParams.set('startTime', sorted[0].timestamp); - analysisParams.set('endTime', sorted[sorted.length - 1].timestamp); - } - const analysisRes = await httpClient.get<{ + const analysisRes = await httpClient.getSessionAnalysis<{ tool_calls?: SessionToolCall[]; - }>( - `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`, - ); + }>(sessionId, botId, { + startTime: sorted[0]?.timestamp, + endTime: sorted[sorted.length - 1]?.timestamp, + }); if (requestId !== messageRequestIdRef.current) return; setToolCalls(analysisRes?.tool_calls ?? []); } catch (analysisError) { if (requestId !== messageRequestIdRef.current) return; console.error('Failed to load session tool calls:', analysisError); setToolCalls([]); + setAnalysisError(true); } // Collect user message IDs for feedback matching @@ -337,6 +346,7 @@ const BotSessionMonitor = forwardRef< } catch (error) { if (requestId === messageRequestIdRef.current) { console.error('Failed to load session messages:', error); + setMessageError(true); } } finally { if (requestId === messageRequestIdRef.current) { @@ -349,6 +359,9 @@ const BotSessionMonitor = forwardRef< useEffect(() => { loadSessions(); + return () => { + sessionRequestIdRef.current += 1; + }; }, [loadSessions]); useEffect(() => { @@ -362,12 +375,17 @@ const BotSessionMonitor = forwardRef< } else { messageRequestIdRef.current += 1; setLoadingMessages(false); + setMessageError(false); + setAnalysisError(false); setMessages([]); setMessageTotal(0); setToolCalls([]); setExpandedToolCallIds({}); setFeedbackMap({}); } + return () => { + messageRequestIdRef.current += 1; + }; }, [selectedSessionId, messagePage, loadMessages]); useEffect(() => { @@ -728,6 +746,20 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.loading')}
+ ) : sessionError ? ( +
+

{t('monitoring.loadError')}

+ +
) : sessions.length === 0 ? (
{t('bots.sessionMonitor.noSessions')} @@ -898,10 +930,46 @@ const BotSessionMonitor = forwardRef< className="flex-1 px-4 py-4 overflow-y-auto min-h-0" >
+ {analysisError && !loadingMessages && ( +
+

+ {t('monitoring.toolCalls.title')}:{' '} + {t('monitoring.loadError')} +

+ +
+ )} {loadingMessages ? (
{t('bots.sessionMonitor.loading')}
+ ) : messageError ? ( +
+

{t('monitoring.loadError')}

+ +
) : timelineItems.length === 0 ? (
{t('bots.sessionMonitor.noMessages')} diff --git a/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx index f37d1523f..cb1e9c1d8 100644 --- a/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx +++ b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx @@ -4,24 +4,18 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react'; import MetricCard from './MetricCard'; import SystemStatusCard from './SystemStatusCards'; import TrafficChart from './TrafficChart'; -import { - OverviewMetrics, - MonitoringMessage, - LLMCall, -} from '../../types/monitoring'; +import { OverviewMetrics, MonitoringData } from '../../types/monitoring'; interface OverviewCardsProps { metrics: OverviewMetrics | null; - messages?: MonitoringMessage[]; - llmCalls?: LLMCall[]; + traffic?: MonitoringData['traffic']; loading?: boolean; refreshKey?: number; } export default function OverviewCards({ metrics, - messages = [], - llmCalls = [], + traffic, loading, refreshKey, }: OverviewCardsProps) { @@ -100,7 +94,7 @@ export default function OverviewCards({
{/* Traffic Chart */} - +
); } diff --git a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx index 674c140e2..fd825299a 100644 --- a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx +++ b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx @@ -11,119 +11,33 @@ import { ResponsiveContainer, Legend, } from 'recharts'; -import { MonitoringMessage, LLMCall } from '../../types/monitoring'; +import { MonitoringData } from '../../types/monitoring'; interface TrafficChartProps { - messages: MonitoringMessage[]; - llmCalls: LLMCall[]; + traffic?: MonitoringData['traffic']; loading?: boolean; } -interface ChartDataPoint { - time: string; - timestamp: number; - messages: number; - llmCalls: number; -} - -export default function TrafficChart({ - messages, - llmCalls, - loading, -}: TrafficChartProps) { +export default function TrafficChart({ traffic, loading }: TrafficChartProps) { const { t } = useTranslation(); - - const chartData = useMemo(() => { - const safeMessages = Array.isArray(messages) ? messages : []; - const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : []; - if (!safeMessages.length && !safeLlmCalls.length) { - return []; - } - - // Combine all timestamps and find the range - const allTimestamps = [ - ...safeMessages.map((m) => m.timestamp.getTime()), - ...safeLlmCalls.map((c) => c.timestamp.getTime()), - ]; - - if (allTimestamps.length === 0) return []; - - const minTime = Math.min(...allTimestamps); - const maxTime = Math.max(...allTimestamps); - const timeRange = maxTime - minTime; - - // Determine bucket size based on time range - let bucketSize: number; - let formatTime: (date: Date) => string; - - if (timeRange <= 60 * 60 * 1000) { - // <= 1 hour: 5-minute buckets - bucketSize = 5 * 60 * 1000; - formatTime = (date) => - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (timeRange <= 6 * 60 * 60 * 1000) { - // <= 6 hours: 15-minute buckets - bucketSize = 15 * 60 * 1000; - formatTime = (date) => - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (timeRange <= 24 * 60 * 60 * 1000) { - // <= 24 hours: 1-hour buckets - bucketSize = 60 * 60 * 1000; - formatTime = (date) => - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (timeRange <= 7 * 24 * 60 * 60 * 1000) { - // <= 7 days: 4-hour buckets - bucketSize = 4 * 60 * 60 * 1000; - formatTime = (date) => - `${date.toLocaleDateString([], { - month: 'short', - day: 'numeric', - })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`; - } else { - // > 7 days: 1-day buckets - bucketSize = 24 * 60 * 60 * 1000; - formatTime = (date) => - date.toLocaleDateString([], { month: 'short', day: 'numeric' }); - } - - // Create buckets - const buckets: Map = new Map(); - const startBucket = Math.floor(minTime / bucketSize) * bucketSize; - const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize; - - for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) { - buckets.set(bucket, { - time: formatTime(new Date(bucket)), - timestamp: bucket, - messages: 0, - llmCalls: 0, - }); - } - - // Count messages per bucket - safeMessages.forEach((msg) => { - const bucket = - Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize; - const point = buckets.get(bucket); - if (point) { - point.messages++; - } - }); - - // Count LLM calls per bucket - safeLlmCalls.forEach((call) => { - const bucket = - Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize; - const point = buckets.get(bucket); - if (point) { - point.llmCalls++; - } - }); - - return Array.from(buckets.values()).sort( - (a, b) => a.timestamp - b.timestamp, - ); - }, [messages, llmCalls]); + const chartData = useMemo( + () => + (traffic?.points ?? []).map((point) => ({ + ...point, + time: point.timestamp.toLocaleString( + [], + traffic?.bucket === 'day' + ? { month: 'short', day: 'numeric' } + : { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }, + ), + })), + [traffic], + ); if (loading) { return ( @@ -150,7 +64,13 @@ export default function TrafficChart({
-
{t('monitoring.trafficChart.noData')}
+
+ {t( + traffic + ? 'monitoring.trafficChart.noData' + : 'monitoring.trafficChart.unavailable', + )} +
); @@ -161,6 +81,11 @@ export default function TrafficChart({

{t('monitoring.trafficChart.title')}

+ {traffic?.truncated && ( +

+ {t('monitoring.trafficChart.truncated')} +

+ )}
(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const workspaceUuid = useCurrentWorkspace()?.workspace.uuid; + const requestIdRef = useRef(0); + const scope = JSON.stringify([workspaceUuid, filterState]); + const [requestScope, setRequestScope] = useState(null); // Memoize filter parameters to prevent unnecessary re-renders const selectedBotsStr = useMemo( @@ -72,6 +77,12 @@ export function useMonitoringData(filterState: FilterState) { // Fetch data based on filters const fetchData = useCallback(async () => { + const requestId = ++requestIdRef.current; + const isCurrent = () => + requestId === requestIdRef.current && + getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid; + setRequestScope(scope); + setData(null); setLoading(true); setError(null); @@ -91,6 +102,7 @@ export function useMonitoringData(filterState: FilterState) { endTime, limit: 50, }); + if (!isCurrent()) return; const overview = response?.overview ?? { total_messages: 0, @@ -127,6 +139,17 @@ export function useMonitoringData(filterState: FilterState) { // Transform the response to match MonitoringData interface const transformedData: MonitoringData = { + traffic: response.traffic + ? { + bucket: response.traffic.bucket, + truncated: response.traffic.truncated, + points: response.traffic.points.map((point) => ({ + timestamp: parseUTCTimestamp(point.timestamp), + messages: point.messages, + llmCalls: point.llm_calls, + })), + } + : undefined, overview: { totalMessages: overview.total_messages, llmCalls: overview.llm_calls, @@ -396,22 +419,33 @@ export function useMonitoringData(filterState: FilterState) { setData(transformedData); } catch (err) { + if (!isCurrent()) return; setError(err as Error); console.error('Failed to fetch monitoring data:', err); } finally { - setLoading(false); + if (isCurrent()) setLoading(false); } - }, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]); + }, [ + getTimeRange, + filterState.selectedBots, + filterState.selectedPipelines, + scope, + workspaceUuid, + ]); // Fetch data when filter state changes useEffect(() => { fetchData(); + return () => { + requestIdRef.current += 1; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ selectedBotsStr, selectedPipelinesStr, filterState.timeRange, customDateRangeStr, + workspaceUuid, ]); // Manual refetch function @@ -420,9 +454,9 @@ export function useMonitoringData(filterState: FilterState) { }; return { - data, - loading, - error, + data: requestScope === scope ? data : null, + loading: requestScope !== scope || loading, + error: requestScope === scope ? error : null, refetch, }; } diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index 0d05a4c1a..a28d8978d 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -32,7 +32,7 @@ function MonitoringPageContent() { currentWorkspace?.permissions.includes('data.export') ?? false; const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } = useMonitoringFilters(); - const { data, loading, refetch } = useMonitoringData(filterState); + const { data, loading, error, refetch } = useMonitoringData(filterState); // Counter to force feedbackTimeRange recomputation on manual refresh const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0); @@ -174,492 +174,556 @@ function MonitoringPageContent() {
{/* Content Area */} -
- {/* Overview Section */} - + {error ? ( +
+

{t('monitoring.loadError')}

+ +
+ ) : ( +
+ {/* Overview Section */} + - {/* Tabs Section */} -
- -
- - - {t('monitoring.tabs.messages')} - - - {t('monitoring.tabs.modelCalls')} - - - {t('monitoring.tabs.tokens')} - - - {t('monitoring.tabs.feedback')} - - - {t('monitoring.tabs.errors')} - - + {/* Tabs Section */} + {!loading && data && ( +
+ {data.totalCount.messages > data.messages.length && ( +

+ {t('monitoring.partialMessages', { + shown: data.messages.length, + total: data.totalCount.messages, + })} +

+ )} + {data.totalCount.llmCalls + data.totalCount.embeddingCalls > + data.modelCalls.length && ( +

+ {t('monitoring.partialModelCalls', { + shown: data.modelCalls.length, + total: + data.totalCount.llmCalls + data.totalCount.embeddingCalls, + })} +

+ )} + {(data.totalCount.toolCalls ?? 0) > data.toolCalls.length && ( +

+ {t('monitoring.partialToolCalls', { + shown: data.toolCalls.length, + total: data.totalCount.toolCalls, + })} +

+ )} + {data.totalCount.errors > data.errors.length && ( +

+ {t('monitoring.partialErrors', { + shown: data.errors.length, + total: data.totalCount.errors, + })} +

+ )}
- - -
- {loading && ( -
- -
- )} - - {!loading && data && conversationTurns.length > 0 && ( - - )} - - {!loading && (!data || conversationTurns.length === 0) && ( -
- -
- {t('monitoring.messageList.noMessages')} -
-
- )} + )} +
+ +
+ + + {t('monitoring.tabs.messages')} + + + {t('monitoring.tabs.modelCalls')} + + + {t('monitoring.tabs.tokens')} + + + {t('monitoring.tabs.feedback')} + + + {t('monitoring.tabs.errors')} + +
- - -
- {loading && ( -
- -
- )} - - {!loading && - data && - data.modelCalls && - data.modelCalls.length > 0 && ( -
- {data.modelCalls.map((call) => ( -
-
-
- {/* Query ID - only show if messageId exists */} - {call.messageId && ( -
- - Query ID: {call.messageId} - - -
- )} -
- {/* Model Type Badge */} - - {call.modelType === 'llm' - ? t('monitoring.modelCalls.llmModel') - : t('monitoring.modelCalls.embeddingModel')} - - {/* Call Type Badge for Embedding */} - {call.modelType === 'embedding' && - call.callType && ( - - {call.callType === 'retrieve' - ? t( - 'monitoring.modelCalls.retrieveCall', - ) - : t( - 'monitoring.modelCalls.embeddingCall', - )} - - )} - {/* Status Badge */} - - {call.status} - -
- {/* Model Name */} -
- {call.modelName} -
- {/* Context Info - only for LLM calls */} - {call.modelType === 'llm' && - call.botName && - call.pipelineName && ( -
- {call.botName} → {call.pipelineName} -
- )} - {/* Token Info */} -
-
- {call.modelType === 'llm' && call.tokens && ( - <> - - {t('monitoring.llmCalls.inputTokens')}:{' '} - {call.tokens.input} - - - {t('monitoring.llmCalls.outputTokens')}:{' '} - {call.tokens.output} - - - {t('monitoring.llmCalls.totalTokens')}:{' '} - {call.tokens.total} - - - )} - {call.modelType === 'embedding' && ( - <> - - {t( - 'monitoring.embeddingCalls.promptTokens', - )} - : {call.promptTokens} - - - {t( - 'monitoring.embeddingCalls.totalTokens', - )} - : {call.totalTokens} - - - {t( - 'monitoring.embeddingCalls.inputCount', - )} - : {call.inputCount} - - - )} - - {t('monitoring.llmCalls.duration')}:{' '} - {call.duration}ms - - {call.cost && ( - - {t('monitoring.llmCalls.cost')}: $ - {call.cost.toFixed(4)} - - )} -
- {/* Knowledge Base Info for Embedding */} - {call.modelType === 'embedding' && - call.knowledgeBaseId && ( -
- {t( - 'monitoring.embeddingCalls.knowledgeBase', - )} - : {call.knowledgeBaseId} -
- )} - {/* Query Text for Embedding Retrieve */} - {call.modelType === 'embedding' && - call.queryText && ( -
- - {t( - 'monitoring.embeddingCalls.queryText', - )} - :{' '} - - - {call.queryText.length > 100 - ? call.queryText.substring(0, 100) + - '...' - : call.queryText} - -
- )} -
- {call.errorMessage && ( -
- Error: {call.errorMessage} -
- )} -
- - {call.timestamp.toLocaleString()} - -
-
- ))} + +
+ {loading && ( +
+
)} - {!loading && - (!data || - !data.modelCalls || - data.modelCalls.length === 0) && ( + {!loading && data && conversationTurns.length > 0 && ( + + )} + + {!loading && (!data || conversationTurns.length === 0) && (
- +
- {t('monitoring.modelCalls.noData')} + {t('monitoring.messageList.noMessages')}
)} -
-
+
+ - - 0 - ? filterState.selectedBots - : undefined - } - pipelineIds={ - filterState.selectedPipelines.length > 0 - ? filterState.selectedPipelines - : undefined - } - startTime={feedbackTimeRange.startTime} - endTime={feedbackTimeRange.endTime} - refreshKey={feedbackRefreshKey} - /> - - - -
- {loading && ( -
- -
- )} - - {!loading && ( - <> - {/* Feedback Stats Cards */} -
- + +
+ {loading && ( +
+
+ )} - {/* Feedback List */} -

- {t('monitoring.feedback.feedbackList')} -

- - - )} -
-
- - -
- {loading && ( -
- -
- )} - - {!loading && data && data.errors && data.errors.length > 0 && ( -
- {data.errors.map((error) => ( -
- {/* Error Header - Always Visible */} -
toggleErrorExpand(error.id)} - > -
-
- {/* Expand Icon */} -
- {expandedErrorId === error.id ? ( - - ) : ( - - )} -
- - {/* Error Info */} + {!loading && + data && + data.modelCalls && + data.modelCalls.length > 0 && ( +
+ {data.modelCalls.map((call) => ( +
+
- {/* Query ID */} -
- - Query ID: {error.messageId || '-'} - - {error.messageId && ( + {/* Query ID - only show if messageId exists */} + {call.messageId && ( +
+ + Query ID: {call.messageId} + - )} -
+
+ )}
- - {error.errorType} + {/* Model Type Badge */} + + {call.modelType === 'llm' + ? t('monitoring.modelCalls.llmModel') + : t( + 'monitoring.modelCalls.embeddingModel', + )} - → - - {error.botName} - - → - - {error.pipelineName} + {/* Call Type Badge for Embedding */} + {call.modelType === 'embedding' && + call.callType && ( + + {call.callType === 'retrieve' + ? t( + 'monitoring.modelCalls.retrieveCall', + ) + : t( + 'monitoring.modelCalls.embeddingCall', + )} + + )} + {/* Status Badge */} + + {call.status}
-

- {error.errorMessage} -

+ {/* Model Name */} +
+ {call.modelName} +
+ {/* Context Info - only for LLM calls */} + {call.modelType === 'llm' && + call.botName && + call.pipelineName && ( +
+ {call.botName} → {call.pipelineName} +
+ )} + {/* Token Info */} +
+
+ {call.modelType === 'llm' && + call.tokens && ( + <> + + {t( + 'monitoring.llmCalls.inputTokens', + )} + : {call.tokens.input} + + + {t( + 'monitoring.llmCalls.outputTokens', + )} + : {call.tokens.output} + + + {t( + 'monitoring.llmCalls.totalTokens', + )} + : {call.tokens.total} + + + )} + {call.modelType === 'embedding' && ( + <> + + {t( + 'monitoring.embeddingCalls.promptTokens', + )} + : {call.promptTokens} + + + {t( + 'monitoring.embeddingCalls.totalTokens', + )} + : {call.totalTokens} + + + {t( + 'monitoring.embeddingCalls.inputCount', + )} + : {call.inputCount} + + + )} + + {t('monitoring.llmCalls.duration')}:{' '} + {call.duration}ms + + {call.cost && ( + + {t('monitoring.llmCalls.cost')}: $ + {call.cost.toFixed(4)} + + )} +
+ {/* Knowledge Base Info for Embedding */} + {call.modelType === 'embedding' && + call.knowledgeBaseId && ( +
+ {t( + 'monitoring.embeddingCalls.knowledgeBase', + )} + : {call.knowledgeBaseId} +
+ )} + {/* Query Text for Embedding Retrieve */} + {call.modelType === 'embedding' && + call.queryText && ( +
+ + {t( + 'monitoring.embeddingCalls.queryText', + )} + :{' '} + + + {call.queryText.length > 100 + ? call.queryText.substring(0, 100) + + '...' + : call.queryText} + +
+ )} +
+ {call.errorMessage && ( +
+ Error: {call.errorMessage} +
+ )}
-
- - {/* Timestamp */} -
- - {error.timestamp.toLocaleString()} + + {call.timestamp.toLocaleString()}
-
+ ))} +
+ )} - {/* Expanded Details */} - {expandedErrorId === error.id && ( -
-
- {/* Error Details */} -
-

- {t('monitoring.errors.errorMessage')} -

-
- {error.errorMessage} + {!loading && + (!data || + !data.modelCalls || + data.modelCalls.length === 0) && ( +
+ +
+ {t('monitoring.modelCalls.noData')} +
+
+ )} +
+ + + + 0 + ? filterState.selectedBots + : undefined + } + pipelineIds={ + filterState.selectedPipelines.length > 0 + ? filterState.selectedPipelines + : undefined + } + startTime={feedbackTimeRange.startTime} + endTime={feedbackTimeRange.endTime} + refreshKey={feedbackRefreshKey} + /> + + + +
+ {loading && ( +
+ +
+ )} + + {!loading && ( + <> + {/* Feedback Stats Cards */} +
+ +
+ + {/* Feedback List */} +

+ {t('monitoring.feedback.feedbackList')} +

+ + + )} +
+
+ + +
+ {loading && ( +
+ +
+ )} + + {!loading && + data && + data.errors && + data.errors.length > 0 && ( +
+ {data.errors.map((error) => ( +
+ {/* Error Header - Always Visible */} +
toggleErrorExpand(error.id)} + > +
+
+ {/* Expand Icon */} +
+ {expandedErrorId === error.id ? ( + + ) : ( + + )} +
+ + {/* Error Info */} +
+ {/* Query ID */} +
+ + Query ID: {error.messageId || '-'} + + {error.messageId && ( + + )} +
+
+ + {error.errorType} + + → + + {error.botName} + + → + + {error.pipelineName} + +
+

+ {error.errorMessage} +

+
+
+ + {/* Timestamp */} +
+ + {error.timestamp.toLocaleString()} +
+
- {/* Context Info */} -
-

- {t('monitoring.messageList.viewDetails')} -

-
-
-
- {t('monitoring.messageList.bot')} -
-
- {error.botName} + {/* Expanded Details */} + {expandedErrorId === error.id && ( +
+
+ {/* Error Details */} +
+

+ {t('monitoring.errors.errorMessage')} +

+
+ {error.errorMessage}
-
-
- {t('monitoring.messageList.pipeline')} -
-
- {error.pipelineName} + + {/* Context Info */} +
+

+ {t('monitoring.messageList.viewDetails')} +

+
+
+
+ {t('monitoring.messageList.bot')} +
+
+ {error.botName} +
+
+
+
+ {t('monitoring.messageList.pipeline')} +
+
+ {error.pipelineName} +
+
+ {error.sessionId && ( +
+
+ {t('monitoring.sessions.sessionId')} +
+
+ {error.sessionId} +
+
+ )}
- {error.sessionId && ( -
-
- {t('monitoring.sessions.sessionId')} -
-
- {error.sessionId} -
+ + {/* Stack Trace */} + {error.stackTrace && ( +
+

+ {t('monitoring.errors.stackTrace')} +

+
+                                        {error.stackTrace}
+                                      
)}
- - {/* Stack Trace */} - {error.stackTrace && ( -
-

- {t('monitoring.errors.stackTrace')} -

-
-                                    {error.stackTrace}
-                                  
-
- )} -
+ )}
- )} + ))}
- ))} -
- )} + )} - {!loading && - (!data || !data.errors || data.errors.length === 0) && ( -
- -
- {t('monitoring.errors.noErrors')} + {!loading && + (!data || !data.errors || data.errors.length === 0) && ( +
+ +
+ {t('monitoring.errors.noErrors')} +
-
- )} -
- - + )} +
+ + +
-
+ )}
); } diff --git a/web/src/app/home/monitoring/types/monitoring.ts b/web/src/app/home/monitoring/types/monitoring.ts index 06e6cc677..8a1f4dc53 100644 --- a/web/src/app/home/monitoring/types/monitoring.ts +++ b/web/src/app/home/monitoring/types/monitoring.ts @@ -217,6 +217,11 @@ export interface FeedbackStats { } export interface MonitoringData { + traffic?: { + bucket: 'hour' | 'day'; + points: Array<{ timestamp: Date; messages: number; llmCalls: number }>; + truncated: boolean; + }; overview: OverviewMetrics; messages: MonitoringMessage[]; llmCalls: LLMCall[]; diff --git a/web/src/app/home/monitoring/utils/conversationTurns.ts b/web/src/app/home/monitoring/utils/conversationTurns.ts index 658540900..b650855c0 100644 --- a/web/src/app/home/monitoring/utils/conversationTurns.ts +++ b/web/src/app/home/monitoring/utils/conversationTurns.ts @@ -155,17 +155,18 @@ function findTurnBySessionTime( sessionTurns: Map, sessionId: string | undefined, timestamp: Date, + botId: string, ): ConversationTurn | undefined { if (!sessionId) { return undefined; } - const turns = sessionTurns.get(sessionId); + const turns = sessionTurns.get(JSON.stringify([botId, sessionId])); if (!turns?.length) { return undefined; } - let nearest = turns[0]; + let nearest: ConversationTurn | undefined; const targetTime = timestamp.getTime(); for (const turn of turns) { @@ -203,15 +204,16 @@ export function buildConversationTurns( for (const message of visibleMessages) { const role = normalizeRole(message, activityMessageIds); - const previousTurn = lastTurnBySession.get(message.sessionId); + const sessionKey = JSON.stringify([message.botId, message.sessionId]); + const previousTurn = lastTurnBySession.get(sessionKey); const shouldStartTurn = role === 'user' || !previousTurn; const turn = shouldStartTurn ? createTurn(message) : previousTurn; if (shouldStartTurn) { - const turns = sessionTurns.get(message.sessionId) ?? []; + const turns = sessionTurns.get(sessionKey) ?? []; turns.push(turn); - sessionTurns.set(message.sessionId, turns); - lastTurnBySession.set(message.sessionId, turn); + sessionTurns.set(sessionKey, turns); + lastTurnBySession.set(sessionKey, turn); } addMessageToTurn(turn, message, role); @@ -221,9 +223,14 @@ export function buildConversationTurns( const allTurns = Array.from(sessionTurns.values()).flat(); for (const call of llmCalls) { - const turn = - (call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ?? - findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp); + const turn = call.messageId + ? messageIdToTurn.get(call.messageId) + : findTurnBySessionTime( + sessionTurns, + call.sessionId, + call.timestamp, + call.botId, + ); if (!turn) { continue; @@ -243,9 +250,14 @@ export function buildConversationTurns( } for (const call of toolCalls) { - const turn = - (call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ?? - findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp); + const turn = call.messageId + ? messageIdToTurn.get(call.messageId) + : findTurnBySessionTime( + sessionTurns, + call.sessionId, + call.timestamp, + call.botId, + ); if (!turn) { continue; @@ -262,9 +274,14 @@ export function buildConversationTurns( } for (const error of errors) { - const turn = - (error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ?? - findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp); + const turn = error.messageId + ? messageIdToTurn.get(error.messageId) + : findTurnBySessionTime( + sessionTurns, + error.sessionId, + error.timestamp, + error.botId, + ); if (!turn) { continue; diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 4e742259d..426c9ab83 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -563,10 +563,24 @@ export class BackendClient extends BaseHttpClient { return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); } + public getSessionAnalysis( + sessionId: string, + botId: string, + options: { startTime?: string; endTime?: string } = {}, + ): Promise { + const queryParams = new URLSearchParams({ botId }); + if (options.startTime) queryParams.set('startTime', options.startTime); + if (options.endTime) queryParams.set('endTime', options.endTime); + return this.get( + `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`, + ); + } + public getSessionMessages( sessionId: string, limit: number = 200, offset: number = 0, + botId?: string, ): Promise<{ messages: Array<{ id: string; @@ -590,6 +604,7 @@ export class BackendClient extends BaseHttpClient { }> { const queryParams = new URLSearchParams(); queryParams.append('sessionId', sessionId); + if (botId) queryParams.append('botId', botId); queryParams.append('limit', limit.toString()); queryParams.append('offset', offset.toString()); return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`); @@ -1496,6 +1511,11 @@ export class BackendClient extends BaseHttpClient { endTime?: string; limit?: number; }): Promise<{ + traffic?: { + bucket: 'hour' | 'day'; + points: Array<{ timestamp: string; messages: number; llm_calls: number }>; + truncated: boolean; + }; overview: { total_messages: number; llm_calls: number; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index e5192ecad..bdab194bc 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1644,7 +1644,16 @@ const enUS = { queryVariables: { title: 'Query Variables', }, + loadError: 'Failed to load monitoring data', + partialMessages: + 'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.', + partialModelCalls: 'Showing {{shown}} of {{total}} model calls.', + partialToolCalls: + 'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.', + partialErrors: 'Showing {{shown}} of {{total}} errors.', trafficChart: { + unavailable: 'Traffic aggregation unavailable', + truncated: 'Traffic range truncated. Choose a shorter time range.', title: 'Traffic Overview', messages: 'Messages', llmCalls: 'LLM Calls', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 9f8f97d99..42a2da720 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1602,7 +1602,17 @@ const esES = { queryVariables: { title: 'Variables de consulta', }, + loadError: 'No se pudieron cargar los datos de monitoreo', + partialMessages: + 'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.', + partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.', + partialToolCalls: + 'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.', + partialErrors: 'Se muestran {{shown}} de {{total}} errores.', trafficChart: { + unavailable: 'Agregación de tráfico no disponible', + truncated: + 'Rango de tráfico truncado. Selecciona un intervalo más corto.', title: 'Resumen de tráfico', messages: 'Mensajes', llmCalls: 'Llamadas LLM', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 37fe6f6b2..ec0f06fdb 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1653,7 +1653,17 @@ const jaJP = { queryVariables: { title: 'クエリ変数', }, + loadError: 'モニタリングデータを読み込めませんでした', + partialMessages: + '全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。', + partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。', + partialToolCalls: + '全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。', + partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。', trafficChart: { + unavailable: 'トラフィック集計を利用できません', + truncated: + 'トラフィック範囲が切り詰められています。短い期間を選択してください。', title: 'トラフィック概要', messages: 'メッセージ', llmCalls: 'LLM呼び出し', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 261c8c9e3..eb707530a 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1574,7 +1574,16 @@ const ruRU = { queryVariables: { title: 'Переменные запроса', }, + loadError: 'Не удалось загрузить данные мониторинга', + partialMessages: + 'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.', + partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.', + partialToolCalls: + 'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.', + partialErrors: 'Показано {{shown}} из {{total}} ошибок.', trafficChart: { + unavailable: 'Агрегированные данные трафика недоступны', + truncated: 'Диапазон трафика обрезан. Выберите более короткий период.', title: 'Обзор трафика', messages: 'Сообщения', llmCalls: 'Вызовы LLM', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 2621d770a..c0a552a9e 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1543,7 +1543,17 @@ const thTH = { queryVariables: { title: 'ตัวแปรคำค้นหา', }, + loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ', + partialMessages: + 'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน', + partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล', + partialToolCalls: + 'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน', + partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด', trafficChart: { + unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน', + truncated: + 'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง', title: 'ภาพรวมปริมาณการใช้งาน', messages: 'ข้อความ', llmCalls: 'การเรียก LLM', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 6e81af16f..a8b9f73a9 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1567,7 +1567,17 @@ const viVN = { queryVariables: { title: 'Biến truy vấn', }, + loadError: 'Không thể tải dữ liệu giám sát', + partialMessages: + 'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.', + partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.', + partialToolCalls: + 'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.', + partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.', trafficChart: { + unavailable: 'Không có dữ liệu tổng hợp lưu lượng', + truncated: + 'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.', title: 'Tổng quan lưu lượng', messages: 'Tin nhắn', llmCalls: 'Cuộc gọi LLM', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index e91090f4d..8fbfc73b8 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1572,7 +1572,16 @@ const zhHans = { queryVariables: { title: '查询变量', }, + loadError: '监控数据加载失败', + partialMessages: + '显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。', + partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。', + partialToolCalls: + '显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。', + partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。', trafficChart: { + unavailable: '流量聚合数据不可用', + truncated: '流量时间范围已截断,请选择更短的时间范围。', title: '流量概览', messages: '消息数', llmCalls: 'LLM调用', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1920a8ba4..75e3e6a4f 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1495,7 +1495,16 @@ const zhHant = { queryVariables: { title: '查詢變數', }, + loadError: '監控資料載入失敗', + partialMessages: + '顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。', + partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。', + partialToolCalls: + '顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。', + partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。', trafficChart: { + unavailable: '流量彙總資料無法使用', + truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。', title: '流量概覽', messages: '訊息', llmCalls: 'LLM呼叫', diff --git a/web/tests/e2e/bot-session-tool-timeline.spec.ts b/web/tests/e2e/bot-session-tool-timeline.spec.ts index 7214f1780..5f580f4a0 100644 --- a/web/tests/e2e/bot-session-tool-timeline.spec.ts +++ b/web/tests/e2e/bot-session-tool-timeline.spec.ts @@ -66,7 +66,381 @@ function toolCall( }; } +test.describe('bot session request recovery', () => { + for (const failure of [ + 'initial list', + 'list page', + 'session switch', + 'message page', + 'analysis', + ]) { + test(`${failure} failure is visible and retry recovers`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let failing = true; + await page.route('**/api/v1/monitoring/**', async (route) => { + const url = new URL(route.request().url()); + const offset = Number(url.searchParams.get('offset') || 0); + const second = url.searchParams.get('sessionId') === 'person-second'; + const list = url.pathname.endsWith('/sessions'); + const message = url.pathname.endsWith('/messages'); + const analysis = url.pathname.endsWith('/analysis'); + if (!list && !message && !analysis) return route.fallback(); + const fail = + failing && + ((list && failure === 'initial list') || + (list && failure === 'list page' && offset > 0) || + (message && failure === 'session switch' && second) || + (message && failure === 'message page' && offset > 0) || + (analysis && failure === 'analysis')); + if (fail) + return route.fulfill({ + status: 500, + json: { code: 500, message: 'fixture failure' }, + }); + const data = list + ? { + sessions: [sessionId, 'person-second'].map((id, i) => ({ + session_id: id, + bot_id: botId, + bot_name: botName, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + message_count: 51, + start_time: at(0), + last_activity: at(4), + is_active: true, + user_name: offset ? `Page two ${i}` : `Recovery user ${i}`, + })), + total: 21, + } + : message + ? { + messages: [ + sessionMessage( + 'recovery-message', + 'user', + 0, + second + ? 'Second session message' + : offset + ? 'Second page message' + : 'Successful message', + ), + ], + total: 51, + } + : { + tool_calls: [ + toolCall('recovery-tool', 1, 'recovered_tool', 40), + ], + }; + return route.fulfill({ json: { code: 0, data } }); + }); + await page.goto(`/home/bots?id=${botId}`); + await page.getByRole('tab', { name: /Sessions/ }).click(); + if (failure === 'list page') { + await page.getByRole('button', { name: 'Next', exact: true }).click(); + } else if (failure !== 'initial list') { + await page.getByRole('button', { name: /Recovery user 0/ }).click(); + if (failure !== 'analysis') { + await expect( + page.getByText('Successful message', { exact: true }), + ).toBeVisible(); + if (failure === 'session switch') + await page.getByRole('button', { name: /Recovery user 1/ }).click(); + else + await page + .getByRole('button', { name: 'Next', exact: true }) + .last() + .click(); + } + } + await expect(page.getByRole('alert')).toBeVisible(); + await expect( + page.getByText('No sessions found', { exact: true }), + ).toHaveCount(0); + if (failure === 'analysis') { + await expect(page.getByRole('alert')).toContainText(/Tool/i); + await expect( + page.getByText('Successful message', { exact: true }), + ).toBeVisible(); + } else { + await expect( + page.getByText('Successful message', { exact: true }), + ).toHaveCount(0); + } + if (failure === 'list page') + await expect( + page.getByRole('button', { name: /Recovery user 0/ }), + ).toHaveCount(0); + failing = false; + await page + .getByRole('alert') + .getByRole('button', { name: 'Retry', exact: true }) + .click(); + await expect(page.getByRole('alert')).toHaveCount(0); + if (failure === 'initial list' || failure === 'list page') { + await expect( + page.getByRole('button', { + name: failure === 'list page' ? /Page two 0/ : /Recovery user 0/, + }), + ).toBeVisible(); + } else { + await expect( + page.getByText( + failure === 'session switch' + ? 'Second session message' + : failure === 'message page' + ? 'Second page message' + : 'Successful message', + { exact: true }, + ), + ).toBeVisible(); + await expect( + page.getByText('recovered_tool', { exact: true }), + ).toBeVisible(); + } + }); + } +}); + +test.describe('bot session request races', () => { + for (const kind of ['messages', 'analysis', 'sessions']) { + for (const status of [200, 500]) { + test(`ignores stale ${kind} ${status} after switching`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let held = false; + let released = false; + await page.route('**/api/v1/monitoring/**', async (route) => { + const url = new URL(route.request().url()); + const list = url.pathname.endsWith('/sessions'); + const message = url.pathname.endsWith('/messages'); + const analysis = url.pathname.endsWith('/analysis'); + if (!list && !message && !analysis) return route.fallback(); + const old = + kind === 'sessions' + ? url.searchParams.get('userQuery') === 'old' + : message + ? url.searchParams.get('sessionId') === sessionId + : url.pathname.includes(sessionId); + const isHeld = old && url.pathname.endsWith(`/${kind}`); + if (isHeld) { + held = true; + await gate; + if (status === 500) { + await route.fulfill({ status: 500, json: { code: 500 } }); + released = true; + return; + } + } + const data = list + ? { + sessions: [sessionId, 'person-new'].map((id, i) => ({ + session_id: id, + bot_id: botId, + bot_name: botName, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + message_count: 1, + start_time: at(0), + last_activity: at(4), + is_active: true, + user_name: isHeld ? 'Stale list' : `Race user ${i}`, + })), + total: 2, + } + : message + ? { + messages: [ + sessionMessage( + 'race-message', + 'user', + 0, + old ? 'Old message' : 'Current message', + ), + ], + total: 1, + } + : { + tool_calls: [ + toolCall( + 'race-tool', + 1, + old ? 'old_tool' : 'current_tool', + 40, + ), + ], + }; + await route.fulfill({ json: { code: 0, data } }); + if (isHeld) released = true; + }); + await page.goto(`/home/bots?id=${botId}`); + await page.getByRole('tab', { name: /Sessions/ }).click(); + if (kind === 'sessions') { + await page + .getByRole('textbox', { name: 'User ID or name' }) + .fill('old'); + await page + .getByRole('textbox', { name: 'User ID or name' }) + .press('Enter'); + } else await page.getByRole('button', { name: /Race user 0/ }).click(); + await expect.poll(() => held).toBe(true); + if (kind === 'sessions') { + await page + .getByRole('textbox', { name: 'User ID or name' }) + .fill('new'); + await page + .getByRole('textbox', { name: 'User ID or name' }) + .press('Enter'); + await expect( + page.getByRole('button', { name: /Race user 0/ }), + ).toBeVisible(); + } else { + await page.getByRole('button', { name: /Race user 1/ }).click(); + await expect( + page.getByText('Current message', { exact: true }), + ).toBeVisible(); + } + release(); + await expect.poll(() => released).toBe(true); + // Allow the released HTTP response and React's queued update to settle. + await page.waitForTimeout(200); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.getByText('Stale list', { exact: true })).toHaveCount( + 0, + ); + if (kind !== 'sessions') { + await expect( + page.getByText('Current message', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('current_tool', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Old message', { exact: true }), + ).toHaveCount(0); + await expect(page.getByText('old_tool', { exact: true })).toHaveCount( + 0, + ); + } + }); + } + } +}); + test.describe('bot session monitor tool timeline', () => { + test('isolates messages and analysis for two bots sharing a raw session id', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const requests: Array<{ bot: string; path: string }> = []; + await page.route('**/api/v1/monitoring/**', async (route) => { + const url = new URL(route.request().url()); + const selectedBot = url.searchParams.get('botId'); + if ( + !url.pathname.endsWith('/sessions') && + !url.pathname.endsWith('/messages') && + !url.pathname.endsWith('/analysis') + ) { + return route.fallback(); + } + expect(['bot-shared-a', 'bot-shared-b']).toContain(selectedBot); + expect(route.request().headers().authorization).toBe( + 'Bearer playwright-token', + ); + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-playwright', + ); + requests.push({ bot: selectedBot!, path: url.pathname }); + const shared = { + session_id: sessionId, + bot_id: selectedBot, + bot_name: selectedBot, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + message_count: 1, + start_time: at(0), + last_activity: at(4), + is_active: true, + platform: 'person', + user_id: 'shared-user', + user_name: 'Shared User', + }; + const data = url.pathname.endsWith('/sessions') + ? { sessions: [shared], total: 1 } + : url.pathname.endsWith('/messages') + ? { + messages: [ + { + ...sessionMessage( + 'shared-message', + 'user', + 0, + `Message for ${selectedBot}`, + ), + bot_id: selectedBot, + }, + ], + total: 1, + } + : { + session_id: sessionId, + found: true, + tool_calls: [ + { + ...toolCall('shared-tool', 1, `tool_${selectedBot}`, 40), + bot_id: selectedBot, + }, + ], + }; + if (url.pathname.endsWith('/messages')) + expect(url.searchParams.get('sessionId')).toBe(sessionId); + if (url.pathname.endsWith('/analysis')) + expect(decodeURIComponent(url.pathname)).toContain( + `/sessions/${sessionId}/analysis`, + ); + await route.fulfill({ json: { code: 0, data } }); + }); + for (const selectedBot of ['bot-shared-a', 'bot-shared-b']) { + await page.goto(`/home/bots?id=${selectedBot}`); + await page.getByRole('tab', { name: /Sessions/ }).click(); + await page.getByRole('button', { name: /Shared User/ }).click(); + await expect( + page.getByText(`Message for ${selectedBot}`, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(`tool_${selectedBot}`, { exact: true }), + ).toBeVisible(); + const otherBot = + selectedBot === 'bot-shared-a' ? 'bot-shared-b' : 'bot-shared-a'; + await expect( + page.getByText(`Message for ${otherBot}`, { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText(`tool_${otherBot}`, { exact: true }), + ).toHaveCount(0); + expect( + requests.some( + (request) => + request.bot === selectedBot && request.path.endsWith('/messages'), + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.bot === selectedBot && request.path.endsWith('/analysis'), + ), + ).toBe(true); + } + }); test('renders tool calls as left-side agent events interleaved with messages', async ({ page, }) => { @@ -117,11 +491,41 @@ test.describe('bot session monitor tool timeline', () => { }, }); + const monitoringRequests: import('@playwright/test').Request[] = []; + page.on('request', (request) => { + if (request.url().includes('/api/v1/monitoring/')) + monitoringRequests.push(request); + }); await page.goto(`/home/bots?id=${botId}`); await page.getByRole('tab', { name: /Sessions/ }).click(); await page.getByRole('button', { name: /Timeline User/ }).click(); await expect(page.getByText('Need a timeline check')).toBeVisible(); + await expect + .poll(() => + monitoringRequests.some((request) => + request.url().includes('/analysis?'), + ), + ) + .toBe(true); + for (const request of monitoringRequests.filter((request) => + /\/messages\?|\/analysis\?/.test(request.url()), + )) { + const url = new URL(request.url()); + expect(url.searchParams.get('botId')).toBe(botId); + if (url.pathname.endsWith('/analysis')) { + expect(url.searchParams.get('startTime')).toBe(at(0)); + expect(url.searchParams.get('endTime')).toBe(at(4)); + } + expect(request.headers().authorization).toBe('Bearer playwright-token'); + expect(request.headers()['x-workspace-id']).toBe('workspace-playwright'); + if (url.pathname.endsWith('/messages')) + expect(url.searchParams.get('sessionId')).toBe(sessionId); + else + expect(decodeURIComponent(url.pathname)).toContain( + `/sessions/${sessionId}/analysis`, + ); + } await expect( page.getByText('repo_file_read', { exact: true }), ).toBeVisible(); diff --git a/web/tests/e2e/monitoring-turns.spec.ts b/web/tests/e2e/monitoring-turns.spec.ts index 89c98554f..2abc76eb6 100644 --- a/web/tests/e2e/monitoring-turns.spec.ts +++ b/web/tests/e2e/monitoring-turns.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, Route } from '@playwright/test'; import { installLangBotApiMocks } from './fixtures/langbot-api'; import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns'; @@ -271,7 +271,198 @@ function rawMonitoringData() { }; } +async function respond(route: Route, label: string) { + const data = rawMonitoringData(); + data.messages = [rawMessage(message(label, 'user', 10, label))]; + await route.fulfill({ json: { code: 0, data } }); +} + +test.describe('monitoring request contracts', () => { + test('shows failures instead of empty success and retries with auth and Workspace headers', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let failing = true; + await page.route('**/api/v1/monitoring/data?*', async (route) => { + expect(route.request().headers().authorization).toBe( + 'Bearer playwright-token', + ); + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-playwright', + ); + if (failing) + await route.fulfill({ + status: 500, + json: { code: 500, msg: 'fixture database unavailable' }, + }); + else await respond(route, 'Recovered monitoring'); + }); + await page.goto('/home/monitoring'); + await expect(page.getByRole('alert')).toContainText( + 'Failed to load monitoring data', + ); + await expect(page.getByText('No message records')).toHaveCount(0); + failing = false; + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect( + page.getByText('Recovered monitoring', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + + test('latest filter request wins over delayed data and delayed failures', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const pending: Route[] = []; + await page.route('**/api/v1/monitoring/data?*', (route) => { + pending.push(route); + }); + await page.goto('/home/monitoring'); + await expect.poll(() => pending.length).toBe(2); + await page.getByRole('combobox').last().click(); + await page.getByRole('option', { name: /Last 7 days/i }).click(); + await expect.poll(() => pending.length).toBe(3); + await respond(pending[2], 'Latest filter data'); + await expect( + page.getByText('Latest filter data', { exact: true }), + ).toBeVisible(); + await respond(pending[0], 'Obsolete filter data'); + await respond(pending[1], 'Obsolete filter data'); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect( + page.getByText('Latest filter data', { exact: true }), + ).toBeVisible(); + await page + .getByRole('button', { name: 'Refresh Data', exact: true }) + .click(); + await expect.poll(() => pending.length).toBe(4); + await expect( + page.getByText('Obsolete filter data', { exact: true }), + ).toHaveCount(0); + await page.getByRole('combobox').last().click(); + await page.getByRole('option', { name: /Last 24 hours/i }).click(); + await expect.poll(() => pending.length).toBe(5); + await respond(pending[4], 'Current result'); + await expect( + page.getByText('Current result', { exact: true }), + ).toBeVisible(); + await pending[3].fulfill({ + status: 500, + json: { code: 500, msg: 'old failure' }, + }); + await expect( + page.getByText('Current result', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + + test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({ + page, + }) => { + const data = rawMonitoringData(); + data.totalCount.messages = 125; + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: { + ...data, + traffic: { + bucket: 'hour', + truncated: true, + points: [ + { timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 }, + { timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 }, + ], + }, + }, + }); + await page.goto('/home/monitoring'); + await expect( + page.getByText( + 'Showing 7 of 125 messages. Conversation traces may be incomplete.', + ), + ).toBeVisible(); + await expect( + page.getByText('Traffic range truncated. Choose a shorter time range.'), + ).toBeVisible(); + const chart = page.locator('.recharts-wrapper'); + await expect(chart).toHaveCount(1); + await chart + .locator(':scope > .recharts-surface') + .hover({ position: { x: 70, y: 100 } }); + await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText( + '125', + ); + await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText( + '77', + ); + }); + + test('does not invent traffic totals when aggregation is unavailable', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: rawMonitoringData(), + }); + await page.goto('/home/monitoring'); + await expect( + page.getByText('Traffic aggregation unavailable'), + ).toBeVisible(); + await expect(page.locator('.recharts-wrapper')).toHaveCount(0); + }); +}); + test.describe('monitoring conversation turn grouping', () => { + test('does not reassign explicitly linked activity outside the visible page', () => { + const turns = buildConversationTurns( + [message('visible', 'user', 10, 'Visible turn')], + [llmCall('older-call', 11, 'off-page', 10, 5, 40)], + [errorLog('older-error', 11, 'off-page')], + [toolCall('older-tool', 11, 'off-page', 'search', 40)], + ); + expect(turns[0].llmCalls).toEqual([]); + expect(turns[0].toolCalls).toEqual([]); + expect(turns[0].errors).toEqual([]); + }); + + test('does not assign unlinked activity before the first visible turn', () => { + const turns = buildConversationTurns( + [message('visible', 'user', 10, 'Visible turn')], + [llmCall('older-call', 1, undefined, 10, 5, 40)], + [{ ...errorLog('older-error', 1, ''), messageId: undefined }], + [toolCall('older-tool', 1, undefined, 'search', 40)], + ); + expect(turns[0].llmCalls).toEqual([]); + expect(turns[0].toolCalls).toEqual([]); + expect(turns[0].errors).toEqual([]); + }); + + test('isolates same-session messages and activity by bot identity', () => { + const first = message('first', 'user', 1, 'Bot one'); + const other = { + ...message('other', 'user', 2, 'Bot two'), + botId: 'other-bot', + }; + const reply = message('reply', 'assistant', 3, 'Bot one reply'); + const turns = buildConversationTurns( + [first, other, reply], + [llmCall('call', 3, undefined, 10, 5, 40)], + [errorLog('error', 3, first.id)], + [toolCall('tool', 3, undefined, 'search', 40)], + ); + const own = turns.find((turn) => turn.id === first.id)!; + expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']); + expect(own.llmCalls.map((item) => item.id)).toEqual(['call']); + expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']); + expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0); + }); + test('keeps a single user message as one observable turn', () => { const userOnly = message( 'single-user-only', diff --git a/web/tests/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs index f855237f4..38b09f306 100644 --- a/web/tests/unit/session-monitor-pagination.test.mjs +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -134,6 +134,22 @@ test('session tool calls are bounded to the visible message page', () => { const monitor = read( 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', ); - includes(monitor, "analysisParams.set('startTime'", 'analysis page start'); - includes(monitor, "analysisParams.set('endTime'", 'analysis page end'); + includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start'); + includes( + monitor, + 'endTime: sorted[sorted.length - 1]?.timestamp', + 'analysis page end', + ); + includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis'); + const client = read('src/app/infra/http/BackendClient.ts'); + includes( + client, + "queryParams.set('startTime', options.startTime)", + 'analysis start query', + ); + includes( + client, + "queryParams.set('endTime', options.endTime)", + 'analysis end query', + ); }); From 1ea9cd3f6fcdedba233dc70d9c1f49e038f95595 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 11 Sep 2026 14:42:38 +0800 Subject: [PATCH 37/56] fix(pipelines): show the actual sandbox scope restriction (#2527) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../templates/metadata/pipeline/ai.yaml | 47 +++- .../dynamic-form/DynamicFormComponent.tsx | 59 +--- .../dynamic-form/DynamicFormConditions.ts | 71 +++++ .../pipeline-form/BoxScopeContext.ts | 14 + .../pipeline-form/PipelineFormComponent.tsx | 6 +- web/src/app/infra/entities/form/dynamic.ts | 7 + web/tests/e2e/sandbox-scope-tooltip.spec.ts | 232 ++++++++++++++++ web/tests/unit/sandbox-scope-tooltip.test.mjs | 252 ++++++++++++++++++ 8 files changed, 623 insertions(+), 65 deletions(-) create mode 100644 web/src/app/home/components/dynamic-form/DynamicFormConditions.ts create mode 100644 web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts create mode 100644 web/tests/e2e/sandbox-scope-tooltip.spec.ts create mode 100644 web/tests/unit/sandbox-scope-tooltip.test.mjs diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index e16a33f49..dfa588bec 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -143,18 +143,41 @@ stages: operator: eq value: false disabled_tooltip: - en_US: >- - Sandbox scope can't be changed: either the Box sandbox is disabled - or unavailable (enable it in config.yaml with box.enabled = true and - ensure the runtime is reachable), or this deployment pins all - pipelines to a fixed scope. - zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。" - zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。" - ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。" - vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi." - th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว" - es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único." - ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров." + en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope." + zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。" + zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。" + ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。" + vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi." + th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต" + es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance." + ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область." + disabled_tooltip_overrides: + - when: + field: __system.box_scope_forced_global + operator: eq + value: true + tooltip: + en_US: "A global sandbox is enforced; the scope cannot be changed." + zh_Hans: "已强制使用全局沙箱,无法修改作用域。" + zh_Hant: "已強制使用全域沙箱,無法修改作用域。" + ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。" + vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi." + th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้" + es_ES: "Se impone un sandbox global; no se puede cambiar el alcance." + ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя." + - when: + field: __system.box_scope_forced + operator: eq + value: true + tooltip: + en_US: "A fixed sandbox scope is enforced; the scope cannot be changed." + zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。" + zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。" + ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。" + vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi." + th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้" + es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance." + ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя." type: select required: false default: "{launcher_type}_{launcher_id}" diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 039f1df0e..4f398c686 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -46,30 +46,10 @@ import { } from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; - -/** - * Resolve the value referenced by a `show_if.field` string. - * - * Fields prefixed with `__system.` are looked up in the caller-supplied - * `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`). - * All other field names are resolved from the live form values first, then - * fall back to `externalDependentValues`. - */ -function resolveShowIfValue( - field: string, - watchedValues: Record, - externalDependentValues?: Record, - systemContext?: Record, -): unknown { - if (field.startsWith(SYSTEM_FIELD_PREFIX)) { - const key = field.slice(SYSTEM_FIELD_PREFIX.length); - return systemContext?.[key]; - } - if (watchedValues[field] !== undefined) { - return watchedValues[field]; - } - return externalDependentValues?.[field]; -} +import { + resolveDisabledState, + resolveShowIfValue, +} from './DynamicFormConditions'; type DynamicFormValueSpec = Pick< IDynamicFormItemSchema, @@ -675,40 +655,19 @@ export default function DynamicFormComponent({ } } - // ``disable_if`` mirrors ``show_if``'s evaluator but instead of - // hiding the field, leaves it visible and inert. Use it when the - // operator needs to see that the field exists yet cannot edit it - // under the current runtime state (e.g. sandbox-bound fields when - // Box is disabled). - let isDisabledByCondition = false; - if (config.disable_if) { - const dependValue = resolveShowIfValue( - config.disable_if.field, + // Keep locked fields visible and resolve only the applicable reason. + const { isDisabledByCondition, disabledTooltip: tooltip } = + resolveDisabledState( + config, watchedValues as Record, externalDependentValues, systemContext, ); - const cond = config.disable_if; - if (cond.operator === 'eq' && dependValue === cond.value) { - isDisabledByCondition = true; - } else if (cond.operator === 'neq' && dependValue !== cond.value) { - isDisabledByCondition = true; - } else if ( - cond.operator === 'in' && - Array.isArray(cond.value) && - cond.value.includes(dependValue) - ) { - isDisabledByCondition = true; - } - } // All fields are disabled when editing (creation_settings are // immutable) or when ``disable_if`` matches. const isFieldDisabled = !!isEditing || isDisabledByCondition; - const disabledTooltip = - isDisabledByCondition && config.disabled_tooltip - ? extractI18nObject(config.disabled_tooltip) - : ''; + const disabledTooltip = tooltip ? extractI18nObject(tooltip) : ''; const renderDisabledTooltipIcon = () => disabledTooltip ? ( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts b/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts new file mode 100644 index 000000000..f2c44b6ce --- /dev/null +++ b/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts @@ -0,0 +1,71 @@ +import { + SYSTEM_FIELD_PREFIX, + type IDynamicFormItemSchema, + type IShowIfCondition, +} from '@/app/infra/entities/form/dynamic'; + +/** System references use caller context; other fields prefer live form values. */ +export function resolveShowIfValue( + field: string, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +): unknown { + if (field.startsWith(SYSTEM_FIELD_PREFIX)) { + return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)]; + } + if (watchedValues[field] !== undefined) { + return watchedValues[field]; + } + return externalDependentValues?.[field]; +} + +export function matchesFormCondition( + condition: IShowIfCondition, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +): boolean { + const value = resolveShowIfValue( + condition.field, + watchedValues, + externalDependentValues, + systemContext, + ); + switch (condition.operator) { + case 'eq': + return value === condition.value; + case 'neq': + return value !== condition.value; + case 'in': + return Array.isArray(condition.value) && condition.value.includes(value); + default: + return false; + } +} + +export function resolveDisabledState( + config: Pick< + IDynamicFormItemSchema, + 'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides' + >, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +) { + const matches = (condition: IShowIfCondition) => + matchesFormCondition( + condition, + watchedValues, + externalDependentValues, + systemContext, + ); + const isDisabledByCondition = + !!config.disable_if && matches(config.disable_if); + const disabledTooltip = isDisabledByCondition + ? (config.disabled_tooltip_overrides?.find((override) => + matches(override.when), + )?.tooltip ?? config.disabled_tooltip) + : undefined; + return { isDisabledByCondition, disabledTooltip }; +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts b/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts new file mode 100644 index 000000000..7c7b7fdd2 --- /dev/null +++ b/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts @@ -0,0 +1,14 @@ +/** Unavailability takes priority over the deployment's scope restriction. */ +export function getBoxScopeContext( + boxAvailable: boolean, + forcedTemplate?: string, +) { + forcedTemplate = forcedTemplate?.trim(); + return { + box_available: boxAvailable, + box_scope_editable: boxAvailable && !forcedTemplate, + // Only expose forced-scope reasons when the sandbox is available. + box_scope_forced: boxAvailable && !!forcedTemplate, + box_scope_forced_global: boxAvailable && forcedTemplate === '{global}', + }; +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 9a3c0c124..66ae7e238 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -8,6 +8,7 @@ import { import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; +import { getBoxScopeContext } from './BoxScopeContext'; import { systemInfo } from '@/app/infra/http'; import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; @@ -425,13 +426,12 @@ export default function PipelineFormComponent({ // 2. the deployment pins all pipelines to a fixed scope via // ``system.limitation.force_box_session_id_template`` (SaaS). const forcedBoxTemplate = - systemInfo.limitation?.force_box_session_id_template || ''; + systemInfo.limitation?.force_box_session_id_template?.trim() || ''; const boxScopeForced = !!forcedBoxTemplate; const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent'; const stageSystemContext = isLocalAgentStage ? { - box_available: boxAvailable, - box_scope_editable: boxAvailable && !boxScopeForced, + ...getBoxScopeContext(boxAvailable, forcedBoxTemplate), pipeline_id: pipelineId, } : undefined; diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 6f945b678..b99981fc7 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema { disable_if?: IShowIfCondition; /** Tooltip shown next to the field label when ``disable_if`` is active. */ disabled_tooltip?: I18nObject; + /** Optional overrides evaluated in order when ``disable_if`` matches. + * The first matching ``when`` wins; otherwise use ``disabled_tooltip``. + * Conditions use the same operators and value lookup as ``disable_if``. */ + disabled_tooltip_overrides?: { + when: IShowIfCondition; + tooltip: I18nObject; + }[]; /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ scopes?: string[]; diff --git a/web/tests/e2e/sandbox-scope-tooltip.spec.ts b/web/tests/e2e/sandbox-scope-tooltip.spec.ts new file mode 100644 index 000000000..ffc90a779 --- /dev/null +++ b/web/tests/e2e/sandbox-scope-tooltip.spec.ts @@ -0,0 +1,232 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: real app/components, intercepted APIs, no production Box. +// Load the shipped metadata rather than reproducing its tooltip conditions. +const requireFromTest = createRequire(__filename); +const { load } = createRequire(requireFromTest.resolve('eslint'))( + 'js-yaml', +) as { + load: (source: string) => unknown; +}; +const aiMetadata = load( + readFileSync( + resolve( + __dirname, + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + ), + 'utf8', + ), +); +const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const forcedHint = '已强制使用全局沙箱,无法修改作用域。'; + +interface BoxState { + enabled: boolean; + available: boolean; +} + +async function openPipeline(page: Page, box: BoxState, forced = '') { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'zh-Hans' }, + }); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + debug: false, + version: 'sandbox-scope-ui-fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + allow_modify_login_info: true, + disable_models_service: false, + limitation: { + max_bots: -1, + max_pipelines: -1, + max_extensions: -1, + force_box_session_id_template: forced, + }, + outbound_ips: [], + wizard_status: 'completed', + wizard_progress: null, + }, + }, + }), + ); + await page.route('**/api/v1/box/status', (route) => + route.fulfill({ + json: { + code: 0, + data: { + ...box, + profile: 'UI fixture only', + recent_error_count: 0, + active_sessions: 0, + managed_processes: 0, + session_ttl_sec: 3600, + backend: { name: 'ui-fixture', available: box.available }, + }, + }, + }), + ); + await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) => + route.fulfill({ json: { code: 0, data: { tools: [] } } }), + ); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }), + ); + await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) => + route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'sandbox-scope-fixture', + name: 'Sandbox scope — UI fixture only', + description: '', + emoji: '⚙️', + is_default: false, + config: { + ai: { + runner: { runner: 'local-agent' }, + 'local-agent': { + 'box-session-id-template': '{launcher_type}_{launcher_id}', + }, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }, + }, + }, + }), + ); + await page.goto('/home/pipelines?id=sandbox-scope-fixture'); + await page.getByRole('button', { name: 'AI 能力', exact: true }).click(); + // DynamicForm gates this control through its wrapper's pointer-events, + // and its label targets that wrapper rather than the nested select. + const scope = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('沙箱作用域', { exact: true }) }) + .getByRole('combobox'); + await expect(scope).toBeVisible(); + return scope; +} + +async function expectWarning(page: Page, hint: string) { + const warning = page.getByRole('button', { name: hint, exact: true }); + await expect(warning).toBeVisible(); + await warning.hover(); + await expect(page.getByRole('tooltip')).toHaveText(hint); +} + +async function expectNoWarning(page: Page) { + await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount( + 0, + ); + await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0); + await expect(page.getByRole('tooltip')).toHaveCount(0); +} + +test.describe('sandbox scope disabled reason (UI fixtures only)', () => { + for (const scenario of [ + { name: 'Box disabled', enabled: false, available: false, forced: '' }, + { name: 'Box disconnected', enabled: true, available: false, forced: '' }, + { + name: 'unavailable Box takes precedence over forced global', + enabled: true, + available: false, + forced: '{global}', + }, + ]) { + test(scenario.name, async ({ page }) => { + const scope = await openPipeline(page, scenario, scenario.forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + await expect(page.getByRole('button', { name: forcedHint })).toHaveCount( + 0, + ); + }); + } + + for (const forced of ['{global}', ' {global} ']) { + test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openPipeline( + page, + { enabled: true, available: true }, + forced, + ); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expect(scope).toHaveText('全局(所有人共享)'); + await expectWarning(page, forcedHint); + await expect( + page.getByRole('button', { name: unavailableHint }), + ).toHaveCount(0); + }); + } + + for (const forced of ['', ' ']) { + test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openPipeline( + page, + { enabled: true, available: true }, + forced, + ); + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expect(scope).toHaveText('每个会话(推荐)'); + await expectNoWarning(page); + await scope.click(); + await page + .getByRole('option', { name: '全局(所有人共享)', exact: true }) + .click(); + await expect(scope).toHaveText('全局(所有人共享)'); + await expectNoWarning(page); + }); + } + + for (const forced of ['', '{global}']) { + test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({ + page, + }) => { + await page.clock.install(); + const box = { enabled: true, available: false }; + const scope = await openPipeline(page, box, forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await page.mouse.move(0, 0); + + const recovered = page.waitForResponse('**/api/v1/box/status'); + box.available = true; + await page.clock.fastForward(31_000); + await recovered; + if (forced) { + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, forcedHint); + } else { + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expectNoWarning(page); + } + await page.mouse.move(0, 0); + + const disconnected = page.waitForResponse('**/api/v1/box/status'); + box.available = false; + await page.clock.fastForward(31_000); + await disconnected; + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + }); + } +}); diff --git a/web/tests/unit/sandbox-scope-tooltip.test.mjs b/web/tests/unit/sandbox-scope-tooltip.test.mjs new file mode 100644 index 000000000..8a0b9cc66 --- /dev/null +++ b/web/tests/unit/sandbox-scope-tooltip.test.mjs @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { load } = createRequire(require.resolve('eslint'))('js-yaml'); +const metadata = load( + fs.readFileSync( + new URL( + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + import.meta.url, + ), + 'utf8', + ), +); +const scope = metadata.stages + .find((stage) => stage.name === 'local-agent') + .config.find((item) => item.name === 'box-session-id-template'); +const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const globalForced = '已强制使用全局沙箱,无法修改作用域。'; +const customForced = '已强制使用固定沙箱作用域,无法修改作用域。'; + +function loadSource(relativePath) { + const filename = new URL(`../../src/${relativePath}`, import.meta.url); + assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`); + const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loaded = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + (name) => { + if (name === '@/app/infra/entities/form/dynamic') + return loadSource('app/infra/entities/form/dynamic.ts'); + throw new Error(`Unexpected runtime import: ${name}`); + }, + loaded, + loaded.exports, + ); + return loaded.exports; +} + +function policies() { + return { + ...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'), + ...loadSource( + 'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts', + ), + }; +} + +function scopeState(available, forcedTemplate) { + const { getBoxScopeContext, resolveDisabledState } = policies(); + return resolveDisabledState( + scope, + {}, + undefined, + getBoxScopeContext(available, forcedTemplate), + ); +} + +test('sandbox default tooltip explains only unavailability', () => { + assert.equal(scope.disabled_tooltip.zh_Hans, unavailable); +}); + +for (const [name, available, template, expected] of [ + ['Box disabled', false, '', unavailable], + ['Box disconnected', false, undefined, unavailable], + [ + 'unavailable takes precedence over forced global', + false, + '{global}', + unavailable, + ], + [ + 'unavailable takes precedence over forced custom', + false, + '{pipeline_id}', + unavailable, + ], + ['available forced global', true, '{global}', globalForced], + ['available padded forced global', true, ' {global} ', globalForced], + ['available whitespace-only editable', true, ' ', undefined], + ['available forced custom', true, '{pipeline_id}', customForced], + ['available forced literal', true, 'tenant-sandbox', customForced], + ['available editable', true, '', undefined], + ['available without limitation', true, undefined, undefined], +]) { + test(name, () => { + const state = scopeState(available, template); + assert.equal(state.isDisabledByCondition, expected !== undefined); + assert.equal(state.disabledTooltip?.zh_Hans, expected); + }); +} + +test('reason follows availability and forced-scope transitions without mutating metadata', () => { + const snapshot = structuredClone(scope); + for (const [available, template, expected] of [ + [false, '{global}', unavailable], + [true, '{global}', globalForced], + [true, '{pipeline_id}', customForced], + [true, '', undefined], + [false, '', unavailable], + [true, '', undefined], + ]) { + assert.equal( + scopeState(available, template).disabledTooltip?.zh_Hans, + expected, + ); + } + assert.deepEqual(scope, snapshot); +}); + +test('all sandbox reason variants preserve the eight metadata locales', () => { + const locales = [ + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'es_ES', + 'ru_RU', + ].sort(); + assert.equal(scope.disabled_tooltip_overrides?.length, 2); + const messages = [ + scope.disabled_tooltip, + ...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip), + ]; + for (const message of messages) { + assert.deepEqual(Object.keys(message).sort(), locales); + for (const locale of locales) assert.ok(message[locale].trim(), locale); + } + for (const locale of locales) { + assert.equal( + new Set(messages.map((message) => message[locale])).size, + 3, + locale, + ); + assert.equal( + scopeState(false, '{global}').disabledTooltip[locale], + messages[0][locale], + ); + assert.equal( + scopeState(true, '{global}').disabledTooltip[locale], + messages[1][locale], + ); + assert.equal( + scopeState(true, '{pipeline_id}').disabledTooltip[locale], + messages[2][locale], + ); + } +}); + +test('ordinary static disabled tooltip remains compatible', () => { + const { resolveDisabledState } = policies(); + const tooltip = { en_US: 'Read only' }; + const config = { + disable_if: { field: 'locked', operator: 'eq', value: true }, + disabled_tooltip: tooltip, + }; + assert.deepEqual(resolveDisabledState(config, { locked: true }), { + isDisabledByCondition: true, + disabledTooltip: tooltip, + }); + assert.deepEqual(resolveDisabledState(config, { locked: false }), { + isDisabledByCondition: false, + disabledTooltip: undefined, + }); + assert.equal( + resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState({ disable_if: config.disable_if }, { locked: true }) + .disabledTooltip, + undefined, + ); +}); + +test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => { + const { matchesFormCondition, resolveDisabledState } = policies(); + const watched = { mode: 'live', empty: null, '__system.locked': false }; + const external = { mode: 'external', fallback: 3, empty: 'external' }; + const system = { locked: true }; + for (const [condition, expected] of [ + [{ field: 'mode', operator: 'eq', value: 'live' }, true], + [{ field: 'mode', operator: 'eq', value: 'external' }, false], + [{ field: 'fallback', operator: 'neq', value: 4 }, true], + [{ field: 'fallback', operator: 'in', value: [2, 3] }, true], + [{ field: 'fallback', operator: 'in', value: '3' }, false], + [{ field: 'fallback', operator: 'eq', value: '3' }, false], + [{ field: 'empty', operator: 'eq', value: null }, true], + [{ field: '__system.locked', operator: 'eq', value: true }, true], + [{ field: 'absent', operator: 'eq', value: true }, false], + ]) + assert.equal( + matchesFormCondition(condition, watched, external, system), + expected, + ); + const config = { + disable_if: { field: '__system.locked', operator: 'eq', value: true }, + disabled_tooltip: { en_US: 'Default' }, + disabled_tooltip_overrides: [ + { + when: { field: 'mode', operator: 'eq', value: 'external' }, + tooltip: { en_US: 'Wrong' }, + }, + { + when: { field: 'fallback', operator: 'in', value: [3] }, + tooltip: { en_US: 'First match' }, + }, + { + when: { field: 'mode', operator: 'neq', value: 'external' }, + tooltip: { en_US: 'Later match' }, + }, + ], + }; + assert.equal( + resolveDisabledState(config, watched, external, system).disabledTooltip + .en_US, + 'First match', + ); + assert.equal( + resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US, + 'Later match', + ); + assert.equal( + resolveDisabledState(config, watched, external, { locked: false }) + .disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState( + { ...config, disabled_tooltip_overrides: [] }, + watched, + external, + system, + ).disabledTooltip.en_US, + 'Default', + ); + const unmatched = { + ...config, + disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]], + }; + assert.equal( + resolveDisabledState(unmatched, watched, external, system).disabledTooltip + .en_US, + 'Default', + ); +}); From 45d77c3926f624a4835679b0df0c80b07c4fc743 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 11 Sep 2026 17:41:33 +0800 Subject: [PATCH 38/56] fix(plugin): preserve explicit nested installation scope (#2528) * fix(plugin): preserve explicit nested installation scope * fix(deps): pin released RAG runtime SDK 0.5.8 --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- pyproject.toml | 2 +- src/langbot/pkg/plugin/handler.py | 14 +- .../plugin/test_rag_file_transfer_protocol.py | 307 ++++++++++++++++++ .../plugin/test_handler_invocation_scope.py | 193 +++++++++++ uv.lock | 8 +- 5 files changed, 513 insertions(+), 11 deletions(-) create mode 100644 tests/integration/plugin/test_rag_file_transfer_protocol.py create mode 100644 tests/unit_tests/plugin/test_handler_invocation_scope.py diff --git a/pyproject.toml b/pyproject.toml index 0dbb986a9..e4d1f6067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.5.7", + "langbot-plugin==0.5.8", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 8463750f0..b15a0b102 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -48,6 +48,7 @@ from ..utils import constants _DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024 _HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024 +_UNSET_INSTALLATION_SCOPE = object() def _binary_storage_value_limit(ap: Any) -> int: @@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler): self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = ( contextvars.ContextVar( f'{self.__class__.__name__}_{id(self)}_outbound_installation', - default=None, ) ) self._installation_bindings: dict[ @@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler): ) -> InstallationBinding | ActionContext | None: if action_context is not None: return super().resolve_outbound_action_context(action_context) - inbound_context = self.current_action_context - if inbound_context is not None: - return inbound_context - return self._outbound_installation_context.get() + # An explicit scope targets the nested call, not its inbound caller. + # None deliberately clears the context for runtime-scoped actions. + scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE) + if scoped_context is not _UNSET_INSTALLATION_SCOPE: + return typing.cast(InstallationBinding | None, scoped_context) + return self.current_action_context def require_outbound_installation_context(self) -> InstallationBinding: - binding = self._outbound_installation_context.get() + binding = self._outbound_installation_context.get(None) if not isinstance(binding, InstallationBinding): raise ValueError('Host plugin action requires an InstallationBinding scope') return binding diff --git a/tests/integration/plugin/test_rag_file_transfer_protocol.py b/tests/integration/plugin/test_rag_file_transfer_protocol.py new file mode 100644 index 000000000..973a993cd --- /dev/null +++ b/tests/integration/plugin/test_rag_file_transfer_protocol.py @@ -0,0 +1,307 @@ +"""Real Core/SDK protocol regression tests; no subprocesses or external services. + +Run against the intended local SDK (``uv run --no-sync`` after local install). +The in-memory transport carries JSON strings through Handler.run on both sides; +send_file, envelope validation, base64 decoding and transfer storage are real. +Only Core's database/object-storage services, parser dispatch/provider and host +sandbox prerequisite probing are doubles. Worker launch/registration is +represented by its already-registered state. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction +from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity +from langbot_plugin.runtime.context import RuntimeContext +from langbot_plugin.runtime.io.connection import Connection +from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError +from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler +from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler +from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler +from langbot_plugin.runtime.plugin.mgr import PluginManager +from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV + + +pytestmark = pytest.mark.asyncio +PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff' +BINDING = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=7, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=3, + artifact_digest='a' * 64, +) +LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'})) + + +class QueueConnection(Connection): + """Only the byte transport is replaced, not the request/response machinery.""" + + def __init__(self): + self.incoming = asyncio.Queue() + self.sent = [] + self.peer = None + + async def send(self, message: str) -> None: + assert isinstance(message, str) + self.sent.append(json.loads(message)) + await self.peer.incoming.put(message) + + async def receive(self) -> str: + message = await self.incoming.get() + if message is None: + raise ConnectionClosedError('test transport closed') + return message + + async def close(self) -> None: + await self.incoming.put(None) + await self.peer.incoming.put(None) + + +def connection_pair(): + left, right = QueueConnection(), QueueConnection() + left.peer, right.peer = right, left + return left, right + + +@asynccontextmanager +async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY): + monkeypatch.chdir(tmp_path) + stored = tmp_path / 'original.bin' + stored.write_bytes(PAYLOAD) + storage_calls = [] + + async def get_file_stream(execution_context, storage_path): + storage_calls.append((execution_context, storage_path)) + assert execution_context.workspace_uuid == BINDING.workspace_uuid + assert storage_path == 'knowledge/original.bin' + return stored.read_bytes() + + async def get_execution_binding(workspace_uuid, expected_generation): + assert workspace_uuid == BINDING.workspace_uuid + assert expected_generation == BINDING.placement_generation + return BINDING + + setting = SimpleNamespace( + plugin_author='tester', + plugin_name='engine', + installation_uuid=BINDING.installation_uuid, + runtime_revision=BINDING.runtime_revision, + artifact_digest=BINDING.artifact_digest, + ) + app = SimpleNamespace( + deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'), + logger=logging.getLogger(__name__), + persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))), + workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding), + rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream), + ) + core_conn, control_conn = connection_pair() + monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer')) + core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app) + core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine') + runtime = RuntimeContext() + runtime.plugin_mgr = PluginManager(runtime) + # No worker is launched: omit only host nsjail/cgroup prerequisite probing. + monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None) + monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer')) + control = ControlConnectionHandler(control_conn, runtime) + runtime.activate_control_handler(control) + bridge_conn, plugin_conn = connection_pair() + bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer')) + plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer')) + # Trusted state left by registration, not plugin-supplied action data. + bridge.bind_action_context(binding) + runtime.plugin_mgr.plugin_handlers.append(bridge) + runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge)) + handlers = [core, control, bridge, plugin] + tasks = [asyncio.create_task(handler.run()) for handler in handlers] + try: + await asyncio.wait_for( + core.set_runtime_config( + runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'), + worker_policy=PluginWorkerPolicy( + max_cpus=1, + max_memory_mb=128, + max_pids=32, + max_open_files=64, + max_file_size_mb=8, + require_hard_limits=False, + ), + runtime_profile=profile, + cloud_service_url=None, + ), + 5, + ) + if isinstance(binding, InstallationBinding): + runtime.activate_installation_binding(binding) + else: + runtime.bind_workspace(binding) + yield SimpleNamespace( + core=core, + control=control, + runtime=runtime, + bridge=bridge, + plugin=plugin, + core_conn=core_conn, + control_conn=control_conn, + bridge_conn=bridge_conn, + plugin_conn=plugin_conn, + app=app, + storage_calls=storage_calls, + ) + finally: + for handler in handlers: + await handler.close() + await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5) + + +def assert_chunks(connection, binding, payload=PAYLOAD): + chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value] + expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH + assert expected > 1 + assert len(chunks) == expected + assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected)) + assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected} + assert all(chunk['context'] == binding.model_dump() for chunk in chunks) + assert len({chunk['data']['file_key'] for chunk in chunks}) == 1 + return chunks[0]['data']['file_key'] + + +@pytest.mark.parametrize( + 'profile,binding', + [('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)], + ids=['legacy-oss', 'managed-oss', 'managed-shared'], +) +async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding): + async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack: + # Legacy plugin API sends no authority; Runtime supplies its trusted binding. + result = await asyncio.wait_for( + stack.plugin.call_action( + PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM, + {'storage_path': 'knowledge/original.bin'}, + ), + 5, + ) + assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD + assert len(stack.storage_calls) == 1 + core_key = assert_chunks(stack.core_conn, binding) + plugin_key = assert_chunks(stack.bridge_conn, binding) + assert result['file_key'] == plugin_key != core_key + assert not (Path(stack.control.file_storage_dir) / core_key).exists() + assert not stack.control._owned_transfer_files + callbacks = [ + message + for message in stack.control_conn.sent + if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value + ] + assert len(callbacks) == 1 + assert callbacks[0]['context'] == binding.model_dump() + assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'} + + +async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch): + async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack: + with stack.core.installation_scope(LEGACY): + with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'): + await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5) + assert not list(Path(stack.control.file_storage_dir).iterdir()) + assert not stack.control._owned_transfer_files + + +async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch): + async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack: + candidate = BINDING.model_copy( + update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64} + ) + assert not stack.runtime.is_current_installation_binding(candidate) + with stack.core.installation_scope(candidate): + key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5) + assert_chunks(stack.core_conn, candidate) + assert await stack.control.read_local_file(key) == PAYLOAD + assert not stack.runtime.is_current_installation_binding(candidate) + + +async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch): + async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack: + target = BINDING.model_copy( + update={ + 'installation_uuid': 'parser-installation', + 'runtime_revision': 2, + 'artifact_digest': 'b' * 64, + } + ) + stack.runtime.activate_installation_binding(target) + parser_calls = [] + restored = [] + + async def parse_document(author, name, context_data, file_bytes): + parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes)) + return {'documents': [{'text': 'parsed'}]} + + stack.runtime.plugin_mgr.parse_document = parse_document + + class ParserConnector: + async def require_workspace_context(self, context): + assert context.workspace_uuid == BINDING.workspace_uuid + + async def call_parser(self, plugin_name, context_data, file_bytes): + assert plugin_name == 'tester/parser' + assert stack.core.current_action_context == BINDING + with stack.core.installation_scope(target): + result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes) + restored.append(stack.core.resolve_outbound_action_context(None)) + return result + + stack.app.plugin_connector = ParserConnector() + result = await asyncio.wait_for( + stack.plugin.call_action( + PluginToRuntimeAction.INVOKE_PARSER, + { + 'plugin_author': 'tester', + 'plugin_name': 'parser', + 'storage_path': 'knowledge/original.bin', + 'filename': 'original.bin', + }, + ), + 5, + ) + assert result == {'documents': [{'text': 'parsed'}]} + key = assert_chunks(stack.core_conn, target) + parse_requests = [ + message + for message in stack.core_conn.sent + if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value + ] + assert len(parse_requests) == 1 + assert parse_requests[0]['context'] == target.model_dump() + assert parse_requests[0]['data']['context']['file_key'] == key + assert parser_calls == [ + ( + target, + 'tester', + 'parser', + { + 'mime_type': 'application/octet-stream', + 'filename': 'original.bin', + 'metadata': {}, + }, + PAYLOAD, + ) + ] + assert restored == [BINDING] + assert stack.core.current_action_context is None + assert stack.core.resolve_outbound_action_context(None) is None + assert not (Path(stack.control.file_storage_dir) / key).exists() diff --git a/tests/unit_tests/plugin/test_handler_invocation_scope.py b/tests/unit_tests/plugin/test_handler_invocation_scope.py new file mode 100644 index 000000000..3d1dfd596 --- /dev/null +++ b/tests/unit_tests/plugin/test_handler_invocation_scope.py @@ -0,0 +1,193 @@ +"""Exercise nested installation routing through real Core/SDK wire envelopes.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction +from langbot_plugin.entities.io.req import ActionRequest +from langbot_plugin.entities.io.resp import ActionResponse +from langbot_plugin.runtime.io import handler as sdk_handler + +from langbot.pkg.plugin.connector import PluginRuntimeConnector +from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context + + +class ReplyingConnection(RecordingConnection): + """Replace only the transport, retaining serialization and response routing.""" + + async def send(self, message: str) -> None: + await super().send(message) + request = json.loads(message) + if 'action' in request: + response = ActionResponse.success({'elements': []}) + response.seq_id = request['seq_id'] + await self.handler._route_response(response.seq_id, response.model_dump()) + + @property + def requests(self): + return [request for message in self.sent if 'action' in (request := json.loads(message))] + + +@pytest.fixture +def bridge(monkeypatch): + runtime_handler, app, binding_a = make_handler() + connection = ReplyingConnection() + connection.handler = runtime_handler + runtime_handler.conn = connection + monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4) + binding_b = binding_a.model_copy( + update={ + 'installation_uuid': '00000000-0000-4000-8000-000000000002', + 'runtime_revision': 2, + 'artifact_digest': 'b' * 64, + } + ) + return runtime_handler, app, connection, binding_a, binding_b + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mode', ['managed', 'legacy']) +async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode): + runtime_handler, app, connection, binding_a, binding_b = bridge + app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}}) + app.deployment.mode = 'cloud' if mode == 'managed' else 'oss' + connector = PluginRuntimeConnector(app, AsyncMock()) + connector.handler = runtime_handler + app.plugin_connector = connector + execution_context = runtime_handler._execution_context(binding_a) + setting_b = SimpleNamespace( + installation_uuid=binding_b.installation_uuid, + runtime_revision=binding_b.runtime_revision, + artifact_digest=binding_b.artifact_digest, + install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {}, + ) + connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b)) + connector.require_workspace_context = AsyncMock(return_value=execution_context) + file_bytes = b'parser document' + app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes)) + inbound_context = binding_a + if mode == 'legacy': + inbound_context = workspace_context().for_installation(binding_a.installation_uuid) + setting_a = SimpleNamespace( + plugin_author='author-a', + plugin_name='plugin-a', + installation_uuid=binding_a.installation_uuid, + runtime_revision=binding_a.runtime_revision, + artifact_digest=binding_a.artifact_digest, + ) + app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a) + expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context) + request = ActionRequest.make_request( + 101, + PluginToRuntimeAction.INVOKE_PARSER.value, + {'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'}, + inbound_context, + ) + + await runtime_handler._handle_action(request.model_dump()) + + response = json.loads(connection.sent[-1]) + assert response['code'] == 0, response + chunks = connection.requests[:-1] + parse = connection.requests[-1] + assert len(chunks) == 4 + assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks) + assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value + assert all(request['context'] == expected.model_dump() for request in connection.requests) + assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes + assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']} + connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True) + assert runtime_handler.current_action_context is None + assert runtime_handler.resolve_outbound_action_context(None) is None + + +@pytest.mark.asyncio +async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge): + runtime_handler, _, connection, binding_a, binding_b = bridge + token = runtime_handler._current_action_context.set(binding_a) + try: + with runtime_handler.installation_scope(binding_b): + await runtime_handler.call_action( + LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump() + ) + await runtime_handler.list_parsers() + finally: + runtime_handler._current_action_context.reset(token) + assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2 + assert runtime_handler.resolve_outbound_action_context(None) is None + + +@pytest.mark.asyncio +async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge): + runtime_handler, _, connection, binding_a, binding_b = bridge + token = runtime_handler._current_action_context.set(binding_a) + try: + with runtime_handler.installation_scope(binding_b): + await runtime_handler.ping() + await runtime_handler.list_parsers() + await runtime_handler.list_parsers() + finally: + runtime_handler._current_action_context.reset(token) + assert [request.get('context') for request in connection.requests] == [ + None, + binding_b.model_dump(), + binding_a.model_dump(), + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError]) +async def test_scope_restores_after_exception_or_cancellation(bridge, failure): + runtime_handler, _, connection, binding_a, binding_b = bridge + with runtime_handler.installation_scope(binding_a): + with pytest.raises(failure): + with runtime_handler.installation_scope(binding_b): + await runtime_handler.list_parsers() + raise failure() + await runtime_handler.list_parsers() + await runtime_handler.list_parsers() + assert [request.get('context') for request in connection.requests] == [ + binding_b.model_dump(), + binding_a.model_dump(), + None, + ] + + +@pytest.mark.asyncio +async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge): + runtime_handler, _, connection, binding_a, binding_b = bridge + entered = asyncio.Event() + release = asyncio.Event() + + async def cancelled_invocation(): + with runtime_handler.installation_scope(binding_b): + await runtime_handler.list_parsers() + entered.set() + await release.wait() + + token = runtime_handler._current_action_context.set(binding_a) + task = asyncio.create_task(cancelled_invocation()) + try: + await asyncio.wait_for(entered.wait(), timeout=2) + with runtime_handler.installation_scope(None): + await runtime_handler.list_parsers() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await runtime_handler.list_parsers() + finally: + runtime_handler._current_action_context.reset(token) + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert [request.get('context') for request in connection.requests] == [ + binding_b.model_dump(), + None, + binding_a.model_dump(), + ] + assert runtime_handler.resolve_outbound_action_context(None) is None diff --git a/uv.lock b/uv.lock index 24d57e3ee..b0efa74b7 100644 --- a/uv.lock +++ b/uv.lock @@ -2129,7 +2129,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.7" }, + { name = "langbot-plugin", specifier = "==0.5.8" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2196,7 +2196,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.7" +version = "0.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2217,9 +2217,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" }, ] [[package]] From b594cf23e4a53b8793af282df105400f52dd2251 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 12 Sep 2026 12:17:26 +0800 Subject: [PATCH 39/56] feat(auth): add webauthn authentication support --- .gitignore | 3 + pyproject.toml | 1 + .../pkg/api/http/controller/groups/user.py | 197 ++++++++++ src/langbot/pkg/api/http/service/user.py | 339 ++++++++++++++++++ src/langbot/pkg/entity/persistence/passkey.py | 40 +++ .../versions/0024_passkey_credentials.py | 54 +++ tests/integration/api/test_smoke.py | 6 + .../integration/api/test_user_passkey_api.py | 138 +++++++ .../api/service/test_user_passkey.py | 104 ++++++ uv.lock | 100 ++++++ web/package.json | 1 + web/pnpm-lock.yaml | 17 + .../AccountSettingsPanel.tsx | 186 +++++++++- web/src/app/infra/http/BackendClient.ts | 80 +++++ web/src/app/login/page.tsx | 52 ++- web/src/i18n/locales/en-US.ts | 18 + web/src/i18n/locales/ja-JP.ts | 19 + web/src/i18n/locales/zh-Hans.ts | 17 + 18 files changed, 1370 insertions(+), 2 deletions(-) create mode 100644 src/langbot/pkg/entity/persistence/passkey.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py create mode 100644 tests/integration/api/test_user_passkey_api.py create mode 100644 tests/unit_tests/api/service/test_user_passkey.py diff --git a/.gitignore b/.gitignore index 97a64ba81..db632fb19 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ testsdk/ # Next.js build cache (legacy) web/.next/ +web/.pnpm-home +.tmp +Caddyfile diff --git a/pyproject.toml b/pyproject.toml index e4d1f6067..08d11d7eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ dependencies = [ "botocore>=1.42.39", "litellm>=1.0.0", "valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published + "webauthn>=3.0.0", ] keywords = [ "bot", diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 844be406e..31e6d7daf 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import quart import argon2 import asyncio import datetime import hmac import time +import typing import uuid from urllib.parse import parse_qs, urlsplit @@ -64,6 +67,22 @@ class UserRouterGroup(group.RouterGroup): return redirect_uri + def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]: + origin = '' + if json_data and isinstance(json_data, dict): + origin = json_data.get('origin', '') + if not origin: + origin = quart.request.headers.get('Origin', '') + if not origin: + origin = quart.request.headers.get('Referer', '') + if not origin: + origin = quart.request.url_root.rstrip('/') + + parsed = urlsplit(origin) + rp_id = parsed.hostname or 'localhost' + clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/') + return clean_origin, rp_id + async def initialize(self) -> None: @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def _() -> str: @@ -387,6 +406,8 @@ class UserRouterGroup(group.RouterGroup): capabilities['password_login_enabled'] = False capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode capabilities['invitation_registration_enabled'] = not cloud_mode + capabilities['passkey_login_enabled'] = True + capabilities['passkey_supported'] = True return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @@ -477,6 +498,182 @@ class UserRouterGroup(group.RouterGroup): except Exception: raise + @self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Generate WebAuthn registration options for current account.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = (await quart.request.json) or {} + origin, rp_id = self._extract_origin_and_rp_id(json_data) + + try: + options, challenge_token = await self.ap.user_service.generate_passkey_registration_options( + account_uuid=user_obj.uuid, + rp_id=rp_id, + origin=origin, + rp_name='LangBot', + ) + return self.success(data={'options': options, 'challenge_token': challenge_token}) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Verify WebAuthn registration response and save credential.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + challenge_token = json_data.get('challenge_token') + credential = json_data.get('credential') or json_data.get('response') + name = json_data.get('name') + + if not challenge_token or not credential: + return self.fail(1, 'Missing challenge_token or credential') + + try: + cred = await self.ap.user_service.verify_and_save_passkey_registration( + challenge_token=challenge_token, + credential_data=credential, + name=name, + ) + return self.success( + data={ + 'uuid': cred.uuid, + 'name': cred.name, + 'created_at': cred.created_at.isoformat() if cred.created_at else None, + } + ) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Generate WebAuthn authentication options for passkey login.""" + json_data = (await quart.request.json) or {} + email = json_data.get('email') + origin, rp_id = self._extract_origin_and_rp_id(json_data) + + try: + options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options( + rp_id=rp_id, + origin=origin, + email=email, + ) + return self.success(data={'options': options, 'challenge_token': challenge_token}) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Verify WebAuthn authentication response and log in.""" + json_data = await quart.request.json + challenge_token = json_data.get('challenge_token') + credential = json_data.get('credential') or json_data.get('response') + + if not challenge_token or not credential: + return self.fail(1, 'Missing challenge_token or credential') + + try: + token, user_obj = await self.ap.user_service.verify_passkey_authentication( + challenge_token=challenge_token, + credential_data=credential, + ) + return self.success( + data={ + 'token': token, + 'user': user_obj.user, + } + ) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """List registered passkeys for the current user.""" + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid) + return self.success( + data=[ + { + 'uuid': pk.uuid, + 'name': pk.name, + 'aaguid': pk.aaguid, + 'transports': pk.transports, + 'backed_up': pk.backed_up, + 'created_at': pk.created_at.isoformat() if pk.created_at else None, + 'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None, + } + for pk in passkeys + ] + ) + + @self.route('/passkey/', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str, passkey_uuid: str) -> str: + """Rename a registered passkey.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + name = (json_data.get('name') or '').strip() + if not name: + return self.fail(1, 'Passkey name cannot be empty') + + updated = await self.ap.user_service.rename_user_passkey( + account_uuid=user_obj.uuid, + passkey_uuid=passkey_uuid, + new_name=name, + ) + if not updated: + return self.http_status(404, -1, 'Passkey not found') + return self.success(data={'uuid': updated.uuid, 'name': updated.name}) + + @self.route('/passkey/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str, passkey_uuid: str) -> str: + """Delete/revoke a registered passkey.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + deleted = await self.ap.user_service.delete_user_passkey( + account_uuid=user_obj.uuid, + passkey_uuid=passkey_uuid, + ) + if not deleted: + return self.http_status(404, -1, 'Passkey not found') + return self.success() + async def _handle_space_direct_launch( self, launch_assertion: str, diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 7fadccf6f..773737694 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -4,6 +4,7 @@ import sqlalchemy import argon2 import jwt import datetime +import json import typing import asyncio import dataclasses @@ -12,10 +13,19 @@ import hashlib import secrets import time import uuid +import webauthn +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ....entity.persistence import user +from ....entity.persistence import passkey from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership from ....utils import constants from ....entity.errors import account as account_errors @@ -29,6 +39,9 @@ if typing.TYPE_CHECKING: _SPACE_OAUTH_STATE_MAX_ENTRIES = 4096 _SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64 _SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4 +_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096 +_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64 +_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4 class AccountExistsLoginRequiredError(ValueError): @@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption: launch_workspace_uuid: str | None = None +@dataclasses.dataclass(frozen=True, slots=True) +class PasskeyChallengeData: + challenge: bytes + purpose: typing.Literal['register', 'auth'] + rp_id: str + origin: str + expires_at: float + account_uuid: str | None = None + user_email: str | None = None + + class UserService: ap: Application _create_user_lock: asyncio.Lock @@ -65,6 +89,9 @@ class UserService: self._space_oauth_state_lock = asyncio.Lock() self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {} self._space_oauth_state_expiry_heap: list[tuple[float, str]] = [] + self._passkey_challenge_lock = asyncio.Lock() + self._passkey_challenges: dict[str, PasskeyChallengeData] = {} + self._passkey_challenge_expiry_heap: list[tuple[float, str]] = [] @staticmethod def _space_oauth_state_digest(state: str) -> str: @@ -850,3 +877,315 @@ class UserService: await self._update_space_provider_for_account(local_account, api_key) return await self.get_user_by_email(space_email) + + def _prune_passkey_challenges(self, now: float) -> None: + while self._passkey_challenge_expiry_heap: + expires_at, token = self._passkey_challenge_expiry_heap[0] + entry = self._passkey_challenges.get(token) + if entry is None or entry.expires_at != expires_at: + heapq.heappop(self._passkey_challenge_expiry_heap) + continue + if expires_at > now: + break + heapq.heappop(self._passkey_challenge_expiry_heap) + self._passkey_challenges.pop(token, None) + + max_heap_entries = max( + _PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR, + len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER, + ) + if len(self._passkey_challenge_expiry_heap) > max_heap_entries: + self._passkey_challenge_expiry_heap[:] = [ + (entry.expires_at, token) for token, entry in self._passkey_challenges.items() + ] + heapq.heapify(self._passkey_challenge_expiry_heap) + + async def issue_passkey_challenge( + self, + purpose: typing.Literal['register', 'auth'], + rp_id: str, + origin: str, + *, + account_uuid: str | None = None, + user_email: str | None = None, + ttl_seconds: int = 300, + ) -> tuple[str, bytes]: + now = time.monotonic() + challenge_bytes = secrets.token_bytes(32) + challenge_token = secrets.token_urlsafe(32) + expires_at = now + ttl_seconds + + async with self._passkey_challenge_lock: + self._prune_passkey_challenges(now) + while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES: + if not self._passkey_challenge_expiry_heap: + break + _, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap) + self._passkey_challenges.pop(oldest_token, None) + + self._passkey_challenges[challenge_token] = PasskeyChallengeData( + challenge=challenge_bytes, + purpose=purpose, + rp_id=rp_id, + origin=origin, + expires_at=expires_at, + account_uuid=account_uuid, + user_email=user_email, + ) + heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token)) + + return challenge_token, challenge_bytes + + async def consume_passkey_challenge( + self, + challenge_token: str, + purpose: typing.Literal['register', 'auth'], + ) -> PasskeyChallengeData: + now = time.monotonic() + async with self._passkey_challenge_lock: + self._prune_passkey_challenges(now) + data = self._passkey_challenges.pop(challenge_token, None) + + if data is None or data.expires_at < now: + raise ValueError('Invalid or expired passkey challenge') + if data.purpose != purpose: + raise ValueError('Passkey challenge purpose mismatch') + return data + + async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]: + statement = ( + sqlalchemy.select(passkey.PasskeyCredential) + .where(passkey.PasskeyCredential.account_uuid == account_uuid) + .order_by(passkey.PasskeyCredential.created_at.desc()) + ) + async with self._session_factory()() as session: + result = await session.scalars(statement) + return list(result.all()) + + async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None: + statement = ( + sqlalchemy.select(passkey.PasskeyCredential) + .where(passkey.PasskeyCredential.credential_id == credential_id) + ) + async with self._session_factory()() as session: + return await session.scalar(statement) + + async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None: + statement = ( + sqlalchemy.select(passkey.PasskeyCredential) + .where(passkey.PasskeyCredential.uuid == passkey_uuid) + ) + async with self._session_factory()() as session: + return await session.scalar(statement) + + async def generate_passkey_registration_options( + self, + account_uuid: str, + rp_id: str, + origin: str, + rp_name: str = 'LangBot', + ) -> tuple[dict[str, typing.Any], str]: + account = await self.get_user_by_uuid(account_uuid) + if account is None: + raise ValueError('User not found') + self._require_active_account(account) + + challenge_token, challenge_bytes = await self.issue_passkey_challenge( + purpose='register', + rp_id=rp_id, + origin=origin, + account_uuid=account_uuid, + user_email=account.user, + ) + + existing_passkeys = await self.get_user_passkeys(account_uuid) + exclude_credentials = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) + for pk in existing_passkeys + ] + + options = webauthn.generate_registration_options( + rp_id=rp_id, + rp_name=rp_name, + user_name=account.user, + user_id=account.uuid.encode('utf-8'), + user_display_name=account.user, + challenge=challenge_bytes, + exclude_credentials=exclude_credentials or None, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + ), + ) + + options_dict = json.loads(webauthn.options_to_json(options)) + return options_dict, challenge_token + + async def verify_and_save_passkey_registration( + self, + challenge_token: str, + credential_data: dict[str, typing.Any] | str, + name: str | None = None, + ) -> passkey.PasskeyCredential: + challenge_data = await self.consume_passkey_challenge(challenge_token, 'register') + if not challenge_data.account_uuid: + raise ValueError('Registration challenge must be bound to an account') + + verification = webauthn.verify_registration_response( + credential=credential_data, + expected_challenge=challenge_data.challenge, + expected_rp_id=challenge_data.rp_id, + expected_origin=challenge_data.origin, + require_user_verification=False, + ) + + cred_id_str = bytes_to_base64url(verification.credential_id) + pub_key_str = bytes_to_base64url(verification.credential_public_key) + + transports = None + if isinstance(credential_data, dict): + resp = credential_data.get('response', {}) + if isinstance(resp, dict) and 'transports' in resp: + t_list = resp.get('transports') + if isinstance(t_list, list): + transports = ','.join(str(x) for x in t_list) + + credential_name = (name or '').strip() + if not credential_name: + credential_name = f"Passkey ({datetime.datetime.now().strftime('%Y-%m-%d %H:%M')})" + + record = passkey.PasskeyCredential( + uuid=str(uuid.uuid4()), + account_uuid=challenge_data.account_uuid, + name=credential_name, + credential_id=cred_id_str, + public_key=pub_key_str, + sign_count=verification.sign_count, + aaguid=verification.aaguid, + transports=transports, + backed_up=verification.credential_backed_up, + ) + + async with self._session_factory()() as session: + async with session.begin(): + session.add(record) + await session.flush() + await session.refresh(record) + return record + + async def generate_passkey_authentication_options( + self, + rp_id: str, + origin: str, + email: str | None = None, + ) -> tuple[dict[str, typing.Any], str]: + challenge_token, challenge_bytes = await self.issue_passkey_challenge( + purpose='auth', + rp_id=rp_id, + origin=origin, + user_email=email, + ) + + allow_credentials: list[PublicKeyCredentialDescriptor] | None = None + if email: + user_obj = await self.get_user_by_email(email) + if user_obj: + user_passkeys = await self.get_user_passkeys(user_obj.uuid) + if user_passkeys: + allow_credentials = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) + for pk in user_passkeys + ] + + options = webauthn.generate_authentication_options( + rp_id=rp_id, + challenge=challenge_bytes, + allow_credentials=allow_credentials or None, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + options_dict = json.loads(webauthn.options_to_json(options)) + return options_dict, challenge_token + + async def verify_passkey_authentication( + self, + challenge_token: str, + credential_data: dict[str, typing.Any] | str, + ) -> tuple[str, user.User]: + challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth') + + raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None + if not raw_id: + raise ValueError('Missing credential id') + + stored_credential = await self.get_passkey_by_credential_id(raw_id) + if stored_credential is None: + raise ValueError('Passkey credential not recognized') + + user_obj = await self.get_user_by_uuid(stored_credential.account_uuid) + if user_obj is None: + raise ValueError('Associated user not found') + self._require_active_account(user_obj) + + verification = webauthn.verify_authentication_response( + credential=credential_data, + expected_challenge=challenge_data.challenge, + expected_rp_id=challenge_data.rp_id, + expected_origin=challenge_data.origin, + credential_public_key=base64url_to_bytes(stored_credential.public_key), + credential_current_sign_count=stored_credential.sign_count, + require_user_verification=False, + ) + + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.id == stored_credential.id + ) + ) + if record: + record.sign_count = verification.new_sign_count + record.last_used_at = datetime.datetime.now() + record.backed_up = verification.credential_backed_up + + token = await self.generate_jwt_token(user_obj) + return token, user_obj + + async def rename_user_passkey( + self, + account_uuid: str, + passkey_uuid: str, + new_name: str, + ) -> passkey.PasskeyCredential | None: + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.uuid == passkey_uuid, + passkey.PasskeyCredential.account_uuid == account_uuid, + ) + ) + if record is None: + return None + record.name = new_name + await session.flush() + await session.refresh(record) + return record + + async def delete_user_passkey( + self, + account_uuid: str, + passkey_uuid: str, + ) -> bool: + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.uuid == passkey_uuid, + passkey.PasskeyCredential.account_uuid == account_uuid, + ) + ) + if record is None: + return False + await session.delete(record) + return True diff --git a/src/langbot/pkg/entity/persistence/passkey.py b/src/langbot/pkg/entity/persistence/passkey.py new file mode 100644 index 000000000..b0102c6c0 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/passkey.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid as uuid_lib + +import sqlalchemy + +from .base import Base + + +class PasskeyCredential(Base): + __tablename__ = 'passkey_credentials' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + uuid = sqlalchemy.Column( + sqlalchemy.String(36), + nullable=False, + default=lambda: str(uuid_lib.uuid4()), + ) + account_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + created_at = sqlalchemy.Column( + sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now() + ) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + + __table_args__ = ( + sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True), + sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True), + sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py b/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py new file mode 100644 index 000000000..4941f4e34 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py @@ -0,0 +1,54 @@ +"""add passkey credentials table + +Revision ID: 0024_passkey_credentials +Revises: 0023_bot_scoped_sessions +Create Date: 2026-09-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0024_passkey_credentials' +down_revision = '0023_bot_scoped_sessions' +branch_labels = None +depends_on = None + +_TABLE_NAME = 'passkey_credentials' + + +def upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column( + 'account_uuid', + sa.String(36), + sa.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('credential_id', sa.String(255), nullable=False), + sa.Column('public_key', sa.Text(), nullable=False), + sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('aaguid', sa.String(64), nullable=True), + sa.Column('transports', sa.String(255), nullable=True), + sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + ) + op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True) + op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True) + op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME) + op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME) + op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 642efaf2b..864fa374f 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -310,6 +310,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': True, 'password_login_enabled': True, 'space_login_enabled': False, + 'passkey_login_enabled': True, + 'passkey_supported': True, } fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() @@ -334,6 +336,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': False, 'password_login_enabled': False, 'space_login_enabled': True, + 'passkey_login_enabled': True, + 'passkey_supported': True, } @pytest.mark.asyncio @@ -355,6 +359,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': True, 'password_login_enabled': False, 'space_login_enabled': True, + 'passkey_login_enabled': True, + 'passkey_supported': True, } @pytest.mark.asyncio diff --git a/tests/integration/api/test_user_passkey_api.py b/tests/integration/api/test_user_passkey_api.py new file mode 100644 index 000000000..59be08cfd --- /dev/null +++ b/tests/integration/api/test_user_passkey_api.py @@ -0,0 +1,138 @@ +""" +Integration smoke tests for Passkey API endpoints. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from tests.integration.api.test_smoke import ( + fake_api_app, + mock_circular_import_chain, + quart_test_client, +) + + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')] + + +class TestPasskeyPublicEndpoints: + @pytest.mark.asyncio + async def test_auth_options_endpoint(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={'origin': 'http://localhost:3000'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['challenge_token'] == 'token_123' + assert data['data']['options']['rpId'] == 'localhost' + + @pytest.mark.asyncio + async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app): + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/verify', + json={}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] != 0 + assert 'Missing challenge_token or credential' in data['msg'] + + @pytest.mark.asyncio + async def test_auth_verify_success(self, quart_test_client, fake_api_app): + fake_api_app.user_service.verify_passkey_authentication = AsyncMock( + return_value=('jwt_token_abc', Mock(user='user@example.com')) + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/verify', + json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['token'] == 'jwt_token_abc' + assert data['data']['user'] == 'user@example.com' + + +class TestPasskeyProtectedEndpoints: + @pytest.mark.asyncio + async def test_register_options_requires_auth(self, quart_test_client): + response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={}) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_passkeys_list_requires_auth(self, quart_test_client): + response = await quart_test_client.get('/api/v1/user/passkeys') + assert response.status_code == 401 + + +class TestPasskeyReverseProxyScenarios: + @pytest.mark.asyncio + async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={'origin': 'https://proxy.company.com:8443'}, + headers={'Host': '127.0.0.1:5300'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='proxy.company.com', + origin='https://proxy.company.com:8443', + email=None, + ) + + @pytest.mark.asyncio + async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={}, + headers={'Origin': 'https://bot.example.com'}, + ) + + assert response.status_code == 200 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='bot.example.com', + origin='https://bot.example.com', + email=None, + ) + + @pytest.mark.asyncio + async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={}, + headers={'Referer': 'https://bot.example.com:9000/login'}, + ) + + assert response.status_code == 200 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='bot.example.com', + origin='https://bot.example.com:9000', + email=None, + ) diff --git a/tests/unit_tests/api/service/test_user_passkey.py b/tests/unit_tests/api/service/test_user_passkey.py new file mode 100644 index 000000000..af7360214 --- /dev/null +++ b/tests/unit_tests/api/service/test_user_passkey.py @@ -0,0 +1,104 @@ +""" +Unit tests for Passkey WebAuthn service operations in UserService. +""" + +from __future__ import annotations + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.http.service.user import UserService +from langbot.pkg.entity.persistence.user import AccountStatus, User + + +pytestmark = pytest.mark.asyncio + + +class TestPasskeyChallengeLifecycle: + async def test_challenge_issuance_and_consumption(self): + service = UserService(SimpleNamespace()) + token, challenge_bytes = await service.issue_passkey_challenge( + purpose='register', + rp_id='localhost', + origin='http://localhost:3000', + account_uuid='acc-123', + user_email='user@example.com', + ) + + assert len(token) > 20 + assert len(challenge_bytes) == 32 + + data = await service.consume_passkey_challenge(token, 'register') + assert data.challenge == challenge_bytes + assert data.rp_id == 'localhost' + assert data.origin == 'http://localhost:3000' + assert data.account_uuid == 'acc-123' + assert data.user_email == 'user@example.com' + + # Replay should fail + with pytest.raises(ValueError, match='Invalid or expired passkey challenge'): + await service.consume_passkey_challenge(token, 'register') + + async def test_challenge_purpose_mismatch_fails(self): + service = UserService(SimpleNamespace()) + token, _ = await service.issue_passkey_challenge( + purpose='register', + rp_id='localhost', + origin='http://localhost:3000', + ) + + with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'): + await service.consume_passkey_challenge(token, 'auth') + + async def test_challenge_expiration(self): + service = UserService(SimpleNamespace()) + token, _ = await service.issue_passkey_challenge( + purpose='auth', + rp_id='localhost', + origin='http://localhost:3000', + ttl_seconds=0, + ) + + with pytest.raises(ValueError, match='Invalid or expired passkey challenge'): + await service.consume_passkey_challenge(token, 'auth') + + +class TestPasskeyOptionsGeneration: + async def test_generate_registration_options(self): + service = UserService(SimpleNamespace()) + mock_user = Mock(spec=User) + mock_user.uuid = 'acc-test-uuid' + mock_user.user = 'test@example.com' + mock_user.status = AccountStatus.ACTIVE.value + service.get_user_by_uuid = AsyncMock(return_value=mock_user) + service.get_user_passkeys = AsyncMock(return_value=[]) + + options, token = await service.generate_passkey_registration_options( + account_uuid='acc-test-uuid', + rp_id='localhost', + origin='http://localhost:3000', + rp_name='LangBot Test', + ) + + assert isinstance(options, dict) + assert options['rp']['name'] == 'LangBot Test' + assert options['rp']['id'] == 'localhost' + assert options['user']['name'] == 'test@example.com' + assert 'challenge' in options + assert len(token) > 0 + + async def test_generate_authentication_options_discoverable(self): + service = UserService(SimpleNamespace()) + + options, token = await service.generate_passkey_authentication_options( + rp_id='localhost', + origin='http://localhost:3000', + ) + + assert isinstance(options, dict) + assert options['rpId'] == 'localhost' + assert 'challenge' in options + assert len(token) > 0 diff --git a/uv.lock b/uv.lock index b0efa74b7..ec3b4a942 100644 --- a/uv.lock +++ b/uv.lock @@ -608,6 +608,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] +[[package]] +name = "cbor2" +version = "6.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" }, + { url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" }, + { url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" }, + { url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" }, + { url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" }, + { url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -2085,6 +2133,7 @@ dependencies = [ { name = "urllib3" }, { name = "uv" }, { name = "valkey-glide", marker = "sys_platform != 'win32'" }, + { name = "webauthn" }, { name = "websockets" }, ] @@ -2180,6 +2229,7 @@ requires-dist = [ { name = "urllib3", specifier = ">=2.7.0" }, { name = "uv", specifier = ">=0.11.15" }, { name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" }, + { name = "webauthn", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] provides-extras = ["seekdb"] @@ -4073,6 +4123,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pybase64" version = "1.4.3" @@ -4490,6 +4561,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + [[package]] name = "pypdf2" version = "3.0.1" @@ -6166,6 +6250,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, ] +[[package]] +name = "webauthn" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" diff --git a/web/package.json b/web/package.json index 715bc546d..c213ad44b 100644 --- a/web/package.json +++ b/web/package.json @@ -55,6 +55,7 @@ "@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.7", + "@simplewebauthn/browser": "^14.0.0", "@tailwindcss/postcss": "^4.1.5", "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ce058c568..908595bde 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -93,6 +93,9 @@ dependencies: '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@simplewebauthn/browser': + specifier: ^14.0.0 + version: 14.0.0 '@tailwindcss/postcss': specifier: ^4.1.5 version: 4.1.18 @@ -1846,6 +1849,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1855,6 +1859,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1864,6 +1869,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1873,6 +1879,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1882,6 +1889,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1891,6 +1899,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1942,6 +1951,10 @@ packages: resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} dev: false + /@simplewebauthn/browser@14.0.0: + resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==} + dev: false + /@standard-schema/utils@0.3.0: resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} dev: false @@ -4240,6 +4253,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4259,6 +4273,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -4278,6 +4293,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4297,6 +4313,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx index ec363c421..b03d5060b 100644 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -12,7 +12,17 @@ import { } from '@/components/ui/item'; import { httpClient } from '@/app/infra/http/HttpClient'; import { systemInfo } from '@/app/infra/http'; -import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; +import { + Loader2, + ExternalLink, + KeyRound, + Layers, + Fingerprint, + Plus, + Trash2, + Pencil, +} from 'lucide-react'; +import { startRegistration } from '@simplewebauthn/browser'; import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; import { PanelBody } from '../settings-dialog/panel-layout'; @@ -22,6 +32,16 @@ interface AccountSettingsPanelProps { onEmailResolved?: (email: string) => void; } +interface PasskeyItem { + uuid: string; + name: string; + aaguid?: string; + transports?: string; + backed_up?: boolean; + created_at?: string; + last_used_at?: string; +} + export default function AccountSettingsPanel({ active, onEmailResolved, @@ -33,10 +53,14 @@ export default function AccountSettingsPanel({ const [loading, setLoading] = useState(true); const [spaceBindLoading, setSpaceBindLoading] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passkeys, setPasskeys] = useState([]); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [registeringPasskey, setRegisteringPasskey] = useState(false); useEffect(() => { if (active) { loadUserInfo(); + loadPasskeys(); } }, [active]); @@ -55,6 +79,67 @@ export default function AccountSettingsPanel({ } } + async function loadPasskeys() { + setPasskeyLoading(true); + try { + const list = await httpClient.getPasskeys(); + setPasskeys(list); + } catch { + // ignore + } finally { + setPasskeyLoading(false); + } + } + + const handleAddPasskey = async () => { + setRegisteringPasskey(true); + try { + const { options, challenge_token } = + await httpClient.getPasskeyRegisterOptions(window.location.origin); + const regResp = await startRegistration({ optionsJSON: options }); + const defaultName = + prompt(t('account.passkeyNamePlaceholder')) || undefined; + await httpClient.verifyPasskeyRegister( + challenge_token, + regResp, + defaultName, + ); + toast.success(t('account.passkeyAddedSuccess')); + await loadPasskeys(); + } catch (error: any) { + if (error?.name === 'NotAllowedError') { + // User cancelled + } else { + toast.error(error?.message || t('common.error')); + } + } finally { + setRegisteringPasskey(false); + } + }; + + const handleDeletePasskey = async (uuid: string) => { + if (!confirm(t('account.deletePasskeyConfirm'))) return; + try { + await httpClient.deletePasskey(uuid); + toast.success(t('account.passkeyDeleteSuccess')); + await loadPasskeys(); + } catch (error: any) { + toast.error(error?.message || t('common.error')); + } + }; + + const handleRenamePasskey = async (uuid: string, currentName: string) => { + const newName = prompt(t('account.passkeyName'), currentName); + if (!newName || !newName.trim() || newName === currentName) return; + try { + await httpClient.renamePasskey(uuid, newName.trim()); + toast.success(t('account.passkeyRenameSuccess')); + await loadPasskeys(); + } catch (error: any) { + toast.error(error?.message || t('common.error')); + } + }; + const handleBindSpace = async () => { setSpaceBindLoading(true); try { @@ -148,6 +233,105 @@ export default function AccountSettingsPanel({ )} + + {/* Passkey Section */} +
+
+
+

+ {t('account.passkeySectionTitle')} +

+

+ {t('account.passkeySectionDesc')} +

+
+ +
+ + {passkeyLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ {t('account.noPasskeys')} +
+ ) : ( +
+ {passkeys.map((pk) => ( + + + + + + {pk.name} + + {pk.created_at && ( + + {t('account.passkeyCreated', { + date: new Date( + pk.created_at, + ).toLocaleDateString(), + })} + + )} + {pk.last_used_at && ( + + ·{' '} + {t('account.passkeyLastUsed', { + date: new Date( + pk.last_used_at, + ).toLocaleDateString(), + })} + + )} + + + + + + + + ))} +
+ )} +
)} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 426c9ab83..f449ef969 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1304,12 +1304,92 @@ export class BackendClient extends BaseHttpClient { invitation_registration_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; + passkey_login_enabled?: boolean; + passkey_supported?: boolean; }> { return this.get('/api/v1/user/account-info', undefined, { skipWorkspace: true, }); } + // ============ Passkey (WebAuthn) API ============ + public getPasskeyAuthOptions( + email?: string, + origin?: string, + ): Promise<{ options: any; challenge_token: string }> { + return this.post( + '/api/v1/user/passkey/auth/options', + { email, origin }, + { skipWorkspace: true }, + ); + } + + public verifyPasskeyAuth( + challenge_token: string, + credential: any, + ): Promise<{ token: string; user: string }> { + return this.post( + '/api/v1/user/passkey/auth/verify', + { challenge_token, credential }, + { skipWorkspace: true }, + ); + } + + public getPasskeyRegisterOptions( + origin?: string, + ): Promise<{ options: any; challenge_token: string }> { + return this.post( + '/api/v1/user/passkey/register/options', + { origin }, + { skipWorkspace: true }, + ); + } + + public verifyPasskeyRegister( + challenge_token: string, + credential: any, + name?: string, + ): Promise<{ uuid: string; name: string; created_at?: string }> { + return this.post( + '/api/v1/user/passkey/register/verify', + { challenge_token, credential, name }, + { skipWorkspace: true }, + ); + } + + public getPasskeys(): Promise< + Array<{ + uuid: string; + name: string; + aaguid?: string; + transports?: string; + backed_up?: boolean; + created_at?: string; + last_used_at?: string; + }> + > { + return this.get('/api/v1/user/passkeys', undefined, { + skipWorkspace: true, + }); + } + + public renamePasskey( + uuid: string, + name: string, + ): Promise<{ uuid: string; name: string }> { + return this.patch( + `/api/v1/user/passkey/${encodeURIComponent(uuid)}`, + { name }, + { skipWorkspace: true }, + ); + } + + public deletePasskey(uuid: string): Promise { + return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, { + skipWorkspace: true, + }); + } + // ============ Workspace API ============ public getWorkspaceBootstrap(): Promise { return this.get('/api/v1/workspaces/bootstrap', undefined, { diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 48436017f..598569fd7 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -35,7 +35,9 @@ import { AlertCircle, RefreshCw, Layers, + Fingerprint, } from 'lucide-react'; +import { startAuthentication } from '@simplewebauthn/browser'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -63,6 +65,8 @@ export default function Login() { const [spaceLoading, setSpaceLoading] = useState(false); const [showLocalLogin, setShowLocalLogin] = useState(false); const [showSpaceLogin, setShowSpaceLogin] = useState(false); + const [showPasskeyLogin, setShowPasskeyLogin] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); @@ -90,6 +94,9 @@ export default function Login() { } setShowLocalLogin(res.password_login_enabled !== false); setShowSpaceLogin(res.space_login_enabled !== false); + setShowPasskeyLogin( + res.passkey_login_enabled !== false || Boolean(res.passkey_supported), + ); setLoading(false); // Also check if already logged in @@ -184,6 +191,30 @@ export default function Login() { handleLogin(values.email, values.password); } + async function handlePasskeyLogin() { + setPasskeyLoading(true); + try { + const { options, challenge_token } = + await httpClient.getPasskeyAuthOptions( + undefined, + window.location.origin, + ); + const authResp = await startAuthentication({ optionsJSON: options }); + const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp); + if (await finishLogin(res.token, res.user)) { + toast.success(t('common.passkeyLoginSuccess')); + } + } catch (error: any) { + if (error?.name === 'NotAllowedError') { + // User cancelled the biometric prompt + } else { + toast.error(error?.message || t('common.passkeyLoginFailed')); + } + } finally { + setPasskeyLoading(false); + } + } + function handleLogin(username: string, password: string) { httpClient .authUser(username, password) @@ -324,8 +355,27 @@ export default function Login() {
)} + {showPasskeyLogin && ( +
+ +
+ )} + {/* Divider - only show if both login methods are available */} - {showSpaceLogin && showLocalLogin && ( + {(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index bdab194bc..2f00dc058 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -86,6 +86,10 @@ const enUS = { 'Recommended: Use official stable model APIs and cloud services', loginLocal: 'Login with local account', loginWithPassword: 'Login with password', + loginWithPasskey: 'Sign in with Passkey', + passkeyLoginSuccess: 'Passkey verified successfully, signing in...', + passkeyLoginFailed: 'Failed to sign in with Passkey', + passkeyNotSupported: 'Passkey is not supported on this browser or device', spaceLoginTitle: 'Login with LangBot Account', spaceLoginDescription: 'Scan the QR code or visit the link below to authorize', @@ -1339,6 +1343,20 @@ const enUS = { bindSpaceWarning: 'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.', bindSpaceSuccess: 'LangBot Account bound successfully', + passkeySectionTitle: 'Passkeys', + passkeySectionDesc: + 'Sign in securely without passwords using biometrics or security keys', + addPasskey: 'Add Passkey', + passkeyName: 'Key Name', + passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey', + passkeyCreated: 'Created on {{date}}', + passkeyLastUsed: 'Last used: {{date}}', + noPasskeys: 'No passkeys registered yet', + deletePasskeyConfirm: + 'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.', + passkeyAddedSuccess: 'Passkey added successfully', + passkeyDeleteSuccess: 'Passkey deleted', + passkeyRenameSuccess: 'Passkey renamed successfully', bindSpaceFailed: 'Failed to bind LangBot Account', bindSpaceInvalidState: 'Invalid bind request. Please try again from account settings.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ec0f06fdb..4460f2640 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -87,6 +87,11 @@ const jaJP = { 'おすすめ:公式の安定したモデル API とクラウドサービスを利用', loginLocal: 'ローカルアカウントでログイン', loginWithPassword: 'パスワードでログイン', + loginWithPasskey: 'パスキーでログイン', + passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...', + passkeyLoginFailed: 'パスキーでのログインに失敗しました', + passkeyNotSupported: + 'お使いのブラウザまたはデバイスはパスキーをサポートしていません', spaceLoginTitle: 'LangBot アカウントでログイン', spaceLoginDescription: 'QRコードをスキャンするか、下のリンクにアクセスして認証してください', @@ -1345,6 +1350,20 @@ const jaJP = { bindSpaceWarning: '連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。', bindSpaceSuccess: 'LangBot アカウントの連携に成功しました', + passkeySectionTitle: 'パスキー (Passkey)', + passkeySectionDesc: + '生体認証やセキュリティキーを使って、パスワード不要で安全にログインします', + addPasskey: 'パスキーを追加', + passkeyName: 'キー名', + passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey', + passkeyCreated: '作成日: {{date}}', + passkeyLastUsed: '最終使用: {{date}}', + noPasskeys: '登録されているパスキーはありません', + deletePasskeyConfirm: + 'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。', + passkeyAddedSuccess: 'パスキーが正常に追加されました', + passkeyDeleteSuccess: 'パスキーを削除しました', + passkeyRenameSuccess: 'パスキー名を変更しました', bindSpaceFailed: 'LangBot アカウントの連携に失敗しました', bindSpaceInvalidState: '無効な連携リクエストです。アカウント設定から再度お試しください。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8fbfc73b8..1b9d40ec4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -84,6 +84,10 @@ const zhHans = { spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', loginLocal: '使用本地账号登录', loginWithPassword: '通过密码登录', + loginWithPasskey: '使用 Passkey 登录', + passkeyLoginSuccess: 'Passkey 验证成功,正在登录...', + passkeyLoginFailed: 'Passkey 登录失败', + passkeyNotSupported: '当前浏览器或设备不支持 Passkey', spaceLoginTitle: '通过 LangBot 账号登录', spaceLoginDescription: '扫描二维码或访问下方链接进行授权', spaceLoginUserCode: '您的验证码', @@ -1274,6 +1278,19 @@ const zhHans = { bindSpaceWarning: '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。', bindSpaceSuccess: 'LangBot 账号绑定成功', + passkeySectionTitle: '通行密钥 (Passkey)', + passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录', + addPasskey: '添加通行密钥', + passkeyName: '密钥名称', + passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey', + passkeyCreated: '创建于 {{date}}', + passkeyLastUsed: '上次使用: {{date}}', + noPasskeys: '暂未绑定任何通行密钥', + deletePasskeyConfirm: + '确定要删除此通行密钥吗?删除后将无法使用该密钥登录。', + passkeyAddedSuccess: '通行密钥添加成功', + passkeyDeleteSuccess: '通行密钥已删除', + passkeyRenameSuccess: '通行密钥重命名成功', bindSpaceFailed: '绑定 LangBot 账号失败', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', setPasswordHint: '设置密码后可使用邮箱密码登录', From 9db6650274b1b2d1e102370854ad338d5401be35 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 12 Sep 2026 12:26:51 +0800 Subject: [PATCH 40/56] style(tests): Remove unused time import from test file --- tests/unit_tests/api/service/test_user_passkey.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/api/service/test_user_passkey.py b/tests/unit_tests/api/service/test_user_passkey.py index af7360214..a2aa38b74 100644 --- a/tests/unit_tests/api/service/test_user_passkey.py +++ b/tests/unit_tests/api/service/test_user_passkey.py @@ -4,7 +4,6 @@ Unit tests for Passkey WebAuthn service operations in UserService. from __future__ import annotations -import time from types import SimpleNamespace from unittest.mock import AsyncMock, Mock From 273b8839b91cb74dfd083644efea18849a3fcee1 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 12 Sep 2026 12:33:26 +0800 Subject: [PATCH 41/56] chore(release): prepare LangBot 4.10.11 (#2531) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e4d1f6067..3c0d0e711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.10" +version = "4.10.11" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/uv.lock b/uv.lock index b0efa74b7..70751b846 100644 --- a/uv.lock +++ b/uv.lock @@ -2008,7 +2008,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.10" +version = "4.10.11" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From 137bb4fdb35f854718d2c45db8322f9817510a01 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 12 Sep 2026 12:50:37 +0800 Subject: [PATCH 42/56] fix(ci): recover immutable release PyPI builds (#2532) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .github/workflows/publish-to-pypi.yml | 39 ++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 363acc0d9..50366f093 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -2,6 +2,11 @@ name: Build and Publish to PyPI on: workflow_dispatch: + inputs: + source_ref: + description: 'Existing release tag to publish (for example v4.10.11)' + required: true + type: string release: types: [published] @@ -11,13 +16,39 @@ jobs: permissions: contents: read id-token: write # Required for trusted publishing to PyPI - + steps: - name: Checkout code uses: actions/checkout@v4 with: + ref: ${{ inputs.source_ref || github.sha }} + fetch-depth: 0 persist-credentials: false + - name: Validate release source and version + env: + RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }} + run: | + python3 - <<'PY' + import os + import re + import subprocess + import tomllib + from pathlib import Path + + tag = os.environ['RELEASE_TAG'] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag): + raise SystemExit('source_ref must be an existing release tag: vX.Y.Z') + def revision(ref): + return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip() + if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'): + raise SystemExit('Checked-out commit does not match the release tag') + version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version'] + if version != tag[1:]: + raise SystemExit(f'Package version {version} does not match tag {tag}') + print(f'Validated {tag} at {revision("HEAD")} (package {version})') + PY + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -26,9 +57,9 @@ jobs: - name: Build frontend run: | cd web - npm install -g pnpm - pnpm install - pnpm build + # Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles. + npm install --include=optional + npm run build mkdir -p ../src/langbot/web/dist cp -r dist ../src/langbot/web/ From ec5b8cc8a8ccff6b61405f5b1c25e1e1741b4c8b Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 12 Sep 2026 13:37:14 +0800 Subject: [PATCH 43/56] docs(space): sync Runner usage recommendation contract (#2533) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- skills/skills/langbot-space-ops/SKILL.md | 35 +++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md index c360d4e3f..241cc1238 100644 --- a/skills/skills/langbot-space-ops/SKILL.md +++ b/skills/skills/langbot-space-ops/SKILL.md @@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens), then send it as a Bearer token: ``` -Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`. +Authorization: Bearer +``` + +Requests without a valid PAT get `401 Unauthorized`. ## Client configuration @@ -66,6 +69,36 @@ All tools are read-only. state (available, unprobed, unavailable), then Space recommendation. Each item includes `availability.up`, `last_probed_at`, latency, and HTTP status. +## Runner usage recommendations + +Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and +setup-wizard recommendations, or `runner_usage: "event"` for event processors. +The component kind remains `Runner`. Only these two exact values are accepted; +omit the optional field to preserve unfiltered browsing. + +```json +{"query":"", "runner_usage":"agent", "page":1, "page_size":100} +``` + +Plugin results include `latest_version` and `runner_usages: string[]`, the +explicit union of usages in that latest installable version. Only recommend a +plugin when this array explicitly contains the target usage. Missing, empty, +malformed, or unknown usages must never mean agent-compatible. Event-only +plugins must never enter Agent recommendations. Empty filtered results are +valid while legacy packages await corrected releases; never remove the filter +to fill a recommendation list. + +REST callers use `runner_usage` on both +`POST /api/v1/marketplace/extensions/search` and the compatibility +`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add +`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint. +Usage is ANDed with other filters before pagination and `total`; MCP/Skill items +do not match. Invalid REST values return HTTP 400. + +Open the same filter in the webpage: +`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent` +(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row. + ## Implementation & maintenance (for Space developers) - Server: `internal/controller/mcp/server.go` (official Go MCP SDK From dfde9578c1742e79ad3e8a47103456bcfff60378 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 12 Sep 2026 15:35:47 +0800 Subject: [PATCH 44/56] fix(ci): fix ruff lint errors and postgres legacy migration table exclusion --- src/langbot/pkg/api/http/service/user.py | 18 ++++++------------ src/langbot/pkg/entity/persistence/passkey.py | 4 +--- src/langbot/pkg/persistence/mgr.py | 1 + tests/integration/api/test_user_passkey_api.py | 8 ++------ .../persistence/test_migrations_postgres.py | 2 ++ 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 773737694..d0edf2e9b 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -963,18 +963,14 @@ class UserService: return list(result.all()) async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None: - statement = ( - sqlalchemy.select(passkey.PasskeyCredential) - .where(passkey.PasskeyCredential.credential_id == credential_id) + statement = sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.credential_id == credential_id ) async with self._session_factory()() as session: return await session.scalar(statement) async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None: - statement = ( - sqlalchemy.select(passkey.PasskeyCredential) - .where(passkey.PasskeyCredential.uuid == passkey_uuid) - ) + statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid) async with self._session_factory()() as session: return await session.scalar(statement) @@ -1000,8 +996,7 @@ class UserService: existing_passkeys = await self.get_user_passkeys(account_uuid) exclude_credentials = [ - PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) - for pk in existing_passkeys + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys ] options = webauthn.generate_registration_options( @@ -1051,7 +1046,7 @@ class UserService: credential_name = (name or '').strip() if not credential_name: - credential_name = f"Passkey ({datetime.datetime.now().strftime('%Y-%m-%d %H:%M')})" + credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})' record = passkey.PasskeyCredential( uuid=str(uuid.uuid4()), @@ -1092,8 +1087,7 @@ class UserService: user_passkeys = await self.get_user_passkeys(user_obj.uuid) if user_passkeys: allow_credentials = [ - PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) - for pk in user_passkeys + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys ] options = webauthn.generate_authentication_options( diff --git a/src/langbot/pkg/entity/persistence/passkey.py b/src/langbot/pkg/entity/persistence/passkey.py index b0102c6c0..210e228cb 100644 --- a/src/langbot/pkg/entity/persistence/passkey.py +++ b/src/langbot/pkg/entity/persistence/passkey.py @@ -28,9 +28,7 @@ class PasskeyCredential(Base): aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) - created_at = sqlalchemy.Column( - sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now() - ) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) __table_args__ = ( diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 5d226675b..e80624afb 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -63,6 +63,7 @@ _ALEMBIC_TENANT_TABLES = { 'mcp_servers', 'model_providers', 'codex_credentials', + 'passkey_credentials', 'llm_models', 'embedding_models', 'rerank_models', diff --git a/tests/integration/api/test_user_passkey_api.py b/tests/integration/api/test_user_passkey_api.py index 59be08cfd..bc58f9f28 100644 --- a/tests/integration/api/test_user_passkey_api.py +++ b/tests/integration/api/test_user_passkey_api.py @@ -8,14 +8,10 @@ from unittest.mock import AsyncMock, Mock import pytest -from tests.integration.api.test_smoke import ( - fake_api_app, - mock_circular_import_chain, - quart_test_client, -) +pytest_plugins = ['tests.integration.api.test_smoke'] -pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')] +pytestmark = pytest.mark.integration class TestPasskeyPublicEndpoints: diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 11af89c59..a8283b2ff 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -550,11 +550,13 @@ class TestPostgreSQLWorkspaceMigration: ) assert 'workspaces' not in tables_before_migration assert 'codex_credentials' not in tables_before_migration + assert 'passkey_credentials' not in tables_before_migration await manager._initialize_managed_schema() async with postgres_engine.connect() as conn: assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) + assert 'passkey_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one() workspace = ( (await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'})) From 19526e1400c3548728083c8cabf202e97dde3732 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 12 Sep 2026 15:51:56 +0800 Subject: [PATCH 45/56] test(api): define explicit fixtures for passkey integration tests --- .../integration/api/test_user_passkey_api.py | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/tests/integration/api/test_user_passkey_api.py b/tests/integration/api/test_user_passkey_api.py index bc58f9f28..566b6a0bc 100644 --- a/tests/integration/api/test_user_passkey_api.py +++ b/tests/integration/api/test_user_passkey_api.py @@ -8,10 +8,66 @@ from unittest.mock import AsyncMock, Mock import pytest -pytest_plugins = ['tests.integration.api.test_smoke'] +from tests.factories import FakeApp +from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')] + + +@pytest.fixture(scope='module') +def mock_circular_import_chain(): + class FakeMinimalApplication: + pass + + mock_app = Mock() + mock_app.Application = FakeMinimalApplication + + mock_entities = Mock() + mock_entities.LifecycleControlScope = MockLifecycleControlScope + + clear = [ + 'langbot.pkg.api.http.controller.group', + 'langbot.pkg.api.http.controller.groups', + 'langbot.pkg.api.http.controller.groups.system', + 'langbot.pkg.api.http.controller.groups.user', + 'langbot.pkg.api.http.controller.main', + ] + + with isolated_sys_modules( + mocks={ + 'langbot.pkg.core.app': mock_app, + 'langbot.pkg.core.entities': mock_entities, + }, + clear=clear, + ): + import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401 + + yield + + +@pytest.fixture +def fake_api_app(): + app = FakeApp() + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True}, + } + ) + app.user_service = Mock() + app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token')) + app.user_service.get_user_by_email = AsyncMock(return_value=Mock()) + return app + + +@pytest.fixture +async def quart_test_client(fake_api_app, http_controller_cls): + controller = http_controller_cls(fake_api_app) + await controller.initialize() + + client = controller.quart_app.test_client() + yield client class TestPasskeyPublicEndpoints: From 58cde8c0229707341b936bfb55cace3ef11bbf97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=BC=E6=96=B9?= <93891533+BiFangKNT@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:47:27 +0800 Subject: [PATCH 46/56] Merge pull request #2534 from langbot-app/fix/i18n-passkey-keys fix(i18n): complete passkey keys across all locale files --- web/src/i18n/locales/es-ES.ts | 19 +++++++++++++++++++ web/src/i18n/locales/ru-RU.ts | 19 +++++++++++++++++++ web/src/i18n/locales/th-TH.ts | 18 ++++++++++++++++++ web/src/i18n/locales/vi-VN.ts | 18 ++++++++++++++++++ web/src/i18n/locales/zh-Hant.ts | 17 +++++++++++++++++ 5 files changed, 91 insertions(+) diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 42a2da720..5c8c2edb7 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -89,6 +89,11 @@ const esES = { 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', loginLocal: 'Iniciar sesión con cuenta local', loginWithPassword: 'Iniciar sesión con contraseña', + loginWithPasskey: 'Iniciar sesión con Passkey', + passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...', + passkeyLoginFailed: 'Error al iniciar sesión con Passkey', + passkeyNotSupported: + 'Passkey no es compatible en este navegador o dispositivo', spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot', spaceLoginDescription: 'Escanea el código QR o visita el enlace para autorizar', @@ -1376,6 +1381,20 @@ const esES = { bindSpaceWarning: 'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.', bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente', + passkeySectionTitle: 'Llaves de acceso (Passkeys)', + passkeySectionDesc: + 'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad', + addPasskey: 'Añadir llave de acceso', + passkeyName: 'Nombre de la llave', + passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey', + passkeyCreated: 'Creada el {{date}}', + passkeyLastUsed: 'Último uso: {{date}}', + noPasskeys: 'No hay llaves de acceso registradas', + deletePasskeyConfirm: + '¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.', + passkeyAddedSuccess: 'Llave de acceso añadida con éxito', + passkeyDeleteSuccess: 'Llave de acceso eliminada', + passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito', bindSpaceFailed: 'Error al vincular la cuenta de LangBot', bindSpaceInvalidState: 'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index eb707530a..478af0b97 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -86,6 +86,11 @@ const ruRU = { 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', loginLocal: 'Войти с локальной учётной записью', loginWithPassword: 'Войти с паролем', + loginWithPasskey: 'Войти с помощью Passkey', + passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...', + passkeyLoginFailed: 'Не удалось войти с помощью Passkey', + passkeyNotSupported: + 'Passkey не поддерживается в этом браузере или на устройстве', spaceLoginTitle: 'Войти с аккаунтом LangBot', spaceLoginDescription: 'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации', @@ -1350,6 +1355,20 @@ const ruRU = { bindSpaceWarning: 'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.', bindSpaceSuccess: 'Аккаунт LangBot успешно привязан', + passkeySectionTitle: 'Ключи доступа (Passkey)', + passkeySectionDesc: + 'Безопасный вход без пароля с помощью биометрии или аппаратного ключа', + addPasskey: 'Добавить ключ доступа', + passkeyName: 'Название ключа', + passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey', + passkeyCreated: 'Создан {{date}}', + passkeyLastUsed: 'Последнее использование: {{date}}', + noPasskeys: 'Нет зарегистрированных ключей доступа', + deletePasskeyConfirm: + 'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.', + passkeyAddedSuccess: 'Ключ доступа успешно добавлен', + passkeyDeleteSuccess: 'Ключ доступа удален', + passkeyRenameSuccess: 'Ключ доступа успешно переименован', bindSpaceFailed: 'Не удалось привязать аккаунт LangBot', bindSpaceInvalidState: 'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c0a552a9e..51e1e6c3e 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -86,6 +86,10 @@ const thTH = { 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น', loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน', + loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey', + passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...', + passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว', + passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey', spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot', spaceLoginDescription: 'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์', @@ -1321,6 +1325,20 @@ const thTH = { bindSpaceWarning: 'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot', bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ', + passkeySectionTitle: 'พาสคีย์ (Passkey)', + passkeySectionDesc: + 'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย', + addPasskey: 'เพิ่มพาสคีย์', + passkeyName: 'ชื่อคีย์', + passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey', + passkeyCreated: 'สร้างเมื่อ {{date}}', + passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}', + noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน', + deletePasskeyConfirm: + 'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก', + passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ', + passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว', + passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ', bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว', bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index a8b9f73a9..c30791515 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -87,6 +87,10 @@ const viVN = { 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây', loginLocal: 'Đăng nhập với tài khoản cục bộ', loginWithPassword: 'Đăng nhập bằng mật khẩu', + loginWithPasskey: 'Đăng nhập bằng Passkey', + passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...', + passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại', + passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey', spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot', spaceLoginDescription: 'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền', @@ -1344,6 +1348,20 @@ const viVN = { bindSpaceWarning: 'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.', bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công', + passkeySectionTitle: 'Mã khóa truy cập (Passkey)', + passkeySectionDesc: + 'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật', + addPasskey: 'Thêm mã khóa truy cập', + passkeyName: 'Tên khóa', + passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey', + passkeyCreated: 'Được tạo vào {{date}}', + passkeyLastUsed: 'Sử dụng lần cuối: {{date}}', + noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký', + deletePasskeyConfirm: + 'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.', + passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công', + passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập', + passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công', bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại', bindSpaceInvalidState: 'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 75e3e6a4f..693950c5e 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -84,6 +84,10 @@ const zhHant = { spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', loginLocal: '使用本地帳號登入', loginWithPassword: '透過密碼登入', + loginWithPasskey: '使用 Passkey 登入', + passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...', + passkeyLoginFailed: 'Passkey 登入失敗', + passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey', spaceLoginTitle: '透過 LangBot 帳號登入', spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權', spaceLoginUserCode: '您的驗證碼', @@ -1275,6 +1279,19 @@ const zhHant = { bindSpaceWarning: '綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。', bindSpaceSuccess: 'LangBot 帳號綁定成功', + passkeySectionTitle: '通行密鑰 (Passkey)', + passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入', + addPasskey: '新增通行密鑰', + passkeyName: '金鑰名稱', + passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey', + passkeyCreated: '建立於 {{date}}', + passkeyLastUsed: '上次使用: {{date}}', + noPasskeys: '尚未綁定任何通行密鑰', + deletePasskeyConfirm: + '確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。', + passkeyAddedSuccess: '通行密鑰新增成功', + passkeyDeleteSuccess: '通行密鑰已刪除', + passkeyRenameSuccess: '通行密鑰重新命名成功', bindSpaceFailed: '綁定 LangBot 帳號失敗', bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', setPasswordHint: '設定密碼後可使用電子郵件密碼登入', From d26d0635c53676200b21d5569517905203bb2e9d Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 12 Sep 2026 19:40:30 +0800 Subject: [PATCH 47/56] fix(vector): correct SeekDB adapter semantics (#2536) --- pyproject.toml | 3 +- src/langbot/pkg/vector/vdbs/seekdb.py | 51 +++--- tests/integration/vector/test_seekdb.py | 123 +++++++++++++++ .../unit_tests/test_optional_dependencies.py | 5 +- tests/unit_tests/vector/test_seekdb.py | 96 ++++++++++++ uv.lock | 147 +++++++++--------- 6 files changed, 323 insertions(+), 102 deletions(-) create mode 100644 tests/integration/vector/test_seekdb.py create mode 100644 tests/unit_tests/vector/test_seekdb.py diff --git a/pyproject.toml b/pyproject.toml index 74bd2b7fe..1dc5e3467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,8 @@ classifiers = [ [project.optional-dependencies] seekdb = [ - "pyseekdb==1.1.0.post3", + "pyseekdb==1.4.0.post1", + "pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')", ] [project.urls] diff --git a/src/langbot/pkg/vector/vdbs/seekdb.py b/src/langbot/pkg/vector/vdbs/seekdb.py index 5be28b458..cb56bd897 100644 --- a/src/langbot/pkg/vector/vdbs/seekdb.py +++ b/src/langbot/pkg/vector/vdbs/seekdb.py @@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase): self._collection_configs: Dict[str, HNSWConfiguration] = {} self._runtime_cache_limit = runtime_cache_limit(ap) - self._escape_table = str.maketrans( - { - '\x00': '', - '\\': '\\\\', - "'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES) - '"': '\\"', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', - } - ) - async def close(self) -> None: self._collections.clear() self._collection_configs.clear() @@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase): return coll def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]: - """SeekDB metadata doesn't support \\ and ", insert will error 3104""" - return { - k: v.translate(self._escape_table) - if isinstance(v, str) - else v - if v is None or isinstance(v, (int, float, bool)) - else str(v) - for k, v in meta.items() - if v is not None - } + """Keep supported scalar metadata values without altering strings.""" + return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None} + + @staticmethod + def _relevance_scores_to_distances(results: Dict[str, Any]) -> None: + """Convert SeekDB hybrid relevance scores to lower-is-better distances.""" + distances = results.get('distances') + if not isinstance(distances, list): + return + + results['distances'] = [ + [1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch] + if isinstance(batch, list) + else batch + for batch in distances + ] async def get_or_create_collection(self, collection: str): """Get or create collection (without vector size - will use default).""" @@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase): kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas) if documents is not None: - kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents] - await asyncio.to_thread(coll.add, **kwargs) + kwargs['documents'] = documents + await asyncio.to_thread(coll.upsert, **kwargs) - self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'") + self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'") async def search( self, @@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase): # Route by search type. # pyseekdb's query() always requires embeddings, so full-text and # hybrid modes use hybrid_search() which supports text-only queries - # and returns the same nested-list format with distances. + # and returns relevance scores in the nested ``distances`` field. + returns_relevance_scores = False if search_type == SearchType.FULL_TEXT: if not query_text: return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} @@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase): n_results=k, include=['documents', 'metadatas'], ) + returns_relevance_scores = True elif search_type == SearchType.HYBRID: if not query_text: @@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase): n_results=k, include=['documents', 'metadatas'], ) + returns_relevance_scores = True self.ap.logger.info( f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results." ) @@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase): results = await asyncio.to_thread(coll.query, **query_kwargs) results = self._json_safe(results) + if returns_relevance_scores: + self._relevance_scores_to_distances(results) self.ap.logger.info( f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results" ) diff --git a/tests/integration/vector/test_seekdb.py b/tests/integration/vector/test_seekdb.py new file mode 100644 index 000000000..668072aba --- /dev/null +++ b/tests/integration/vector/test_seekdb.py @@ -0,0 +1,123 @@ +"""Real embedded SeekDB regression tests. + +Install the optional dependency before running these slow tests:: + + uv sync --dev --extra seekdb + uv run pytest tests/integration/vector/test_seekdb.py -m slow -q +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +import uuid + +import pytest + +pytest.importorskip('pyseekdb') + +from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase + + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + + +@pytest.fixture +async def backend(tmp_path): + app = SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'vdb': { + 'runtime_cache_limit': 16, + 'seekdb': { + 'mode': 'embedded', + 'path': str(tmp_path), + 'database': 'langbot_test', + }, + } + } + ), + logger=SimpleNamespace( + info=lambda *args, **kwargs: None, + warning=lambda *args, **kwargs: None, + ), + ) + database = SeekDBVectorDatabase(app) + collection = f'test_{uuid.uuid4().hex}' + yield database, collection + + await database.delete_collection(collection) + await database.close() + + +@pytest.mark.asyncio +async def test_upsert_and_text_round_trip(backend) -> None: + database, collection = backend + original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文' + updated = f'Updated: {original}' + + await database.add_embeddings( + collection, + ['document-a'], + [[1.0, 0.0, 0.0]], + [{'file_id': 'file-a', 'text': original}], + [original], + ) + await database.add_embeddings( + collection, + ['document-a'], + [[0.0, 1.0, 0.0]], + [{'file_id': 'file-a', 'text': updated}], + [updated], + ) + + items, _ = await database.list_by_filter(collection, {'file_id': 'file-a'}) + + assert len(items) == 1 + assert items[0]['id'] == 'document-a' + assert items[0]['document'] == updated + assert items[0]['metadata']['text'] == updated + + +@pytest.mark.asyncio +async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None: + database, collection = backend + documents = [ + 'orchid orchid orchid flower', + 'orchid grows in a garden with many other beautiful plants', + 'a completely unrelated topic', + ] + + await database.add_embeddings( + collection, + ['best', 'weak', 'noise'], + [[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]], + [ + {'file_id': item_id, 'document_id': item_id, 'text': document} + for item_id, document in zip(['best', 'weak', 'noise'], documents, strict=True) + ], + documents, + ) + seekdb_collection = await database.get_or_create_collection(collection) + await asyncio.to_thread(seekdb_collection.refresh_index) + + full_text = await database.search( + collection, + [1.0, 0.0, 0.0], + k=3, + search_type='full_text', + query_text='orchid', + ) + hybrid = await database.search( + collection, + [1.0, 0.0, 0.0], + k=3, + search_type='hybrid', + query_text='orchid', + vector_weight=0.65, + ) + + assert full_text['ids'][0][:2] == ['best', 'weak'] + assert full_text['distances'][0] == sorted(full_text['distances'][0]) + assert hybrid['ids'][0] == ['best', 'weak', 'noise'] + assert hybrid['distances'][0] == sorted(hybrid['distances'][0]) diff --git a/tests/unit_tests/test_optional_dependencies.py b/tests/unit_tests/test_optional_dependencies.py index 841344906..7cbeba89f 100644 --- a/tests/unit_tests/test_optional_dependencies.py +++ b/tests/unit_tests/test_optional_dependencies.py @@ -12,4 +12,7 @@ def test_seekdb_is_only_declared_as_an_optional_dependency() -> None: project = pyproject['project'] base_dependencies = project['dependencies'] assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies) - assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3'] + assert project['optional-dependencies']['seekdb'] == [ + 'pyseekdb==1.4.0.post1', + "pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')", + ] diff --git a/tests/unit_tests/vector/test_seekdb.py b/tests/unit_tests/vector/test_seekdb.py new file mode 100644 index 000000000..74432ab0e --- /dev/null +++ b/tests/unit_tests/vector/test_seekdb.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase + + +def _adapter_with_collection(collection: MagicMock) -> SeekDBVectorDatabase: + adapter = SeekDBVectorDatabase.__new__(SeekDBVectorDatabase) + adapter.ap = SimpleNamespace(logger=MagicMock()) + adapter.client = MagicMock() + adapter.client.has_collection.return_value = True + adapter._collections = {'knowledge_base': collection} + adapter._runtime_cache_limit = 16 + return adapter + + +@pytest.mark.asyncio +async def test_add_embeddings_upserts_and_preserves_text() -> None: + collection = MagicMock() + adapter = _adapter_with_collection(collection) + adapter._get_or_create_collection_internal = AsyncMock(return_value=collection) + original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文' + + await adapter.add_embeddings( + collection='knowledge_base', + ids=['document-a'], + embeddings_list=[[1.0, 0.0, 0.0]], + metadatas=[{'text': original}], + documents=[original], + ) + + collection.upsert.assert_called_once_with( + ids=['document-a'], + embeddings=[[1.0, 0.0, 0.0]], + metadatas=[{'text': original}], + documents=[original], + ) + collection.add.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('search_type', 'scores', 'expected_distances'), + [ + ('full_text', [0.4508196721, 0.25], [0.5491803279, 0.75]), + ('hybrid', [0.0328, 0.0323, 0.0159], [0.9672, 0.9677, 0.9841]), + ], +) +async def test_search_converts_relevance_scores_to_distances( + search_type: str, + scores: list[float], + expected_distances: list[float], +) -> None: + collection = MagicMock() + collection.hybrid_search.return_value = { + 'ids': [['best', 'weak', 'noise'][: len(scores)]], + 'metadatas': [[{} for _ in scores]], + 'distances': [scores], + } + adapter = _adapter_with_collection(collection) + + results = await adapter.search( + collection='knowledge_base', + query_embedding=[1.0, 0.0, 0.0], + k=len(scores), + search_type=search_type, + query_text='orchid', + vector_weight=0.65, + ) + + assert results['distances'][0] == pytest.approx(expected_distances) + assert results['distances'][0] == sorted(results['distances'][0]) + + +@pytest.mark.asyncio +async def test_vector_search_keeps_seekdb_cosine_distances() -> None: + collection = MagicMock() + collection.query.return_value = { + 'ids': [['best', 'weak']], + 'metadatas': [[{}, {}]], + 'distances': [[0.1, 0.25]], + } + adapter = _adapter_with_collection(collection) + + results = await adapter.search( + collection='knowledge_base', + query_embedding=[1.0, 0.0, 0.0], + k=2, + search_type='vector', + ) + + assert results['distances'] == [[0.1, 0.25]] diff --git a/uv.lock b/uv.lock index 51b77be61..8d718eb73 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1099,34 +1099,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime" }, + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft" }, + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufile = [ - { name = "nvidia-cufile" }, + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti" }, + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand" }, + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver" }, + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx" }, + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -2139,6 +2139,7 @@ dependencies = [ [package.optional-dependencies] seekdb = [ + { name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" }, { name = "pyseekdb" }, ] @@ -2203,10 +2204,11 @@ requires-dist = [ { name = "pycryptodome", specifier = ">=3.22.0" }, { name = "pydantic", specifier = ">2.0" }, { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = "==1.4.0" }, { name = "pymilvus", specifier = ">=2.6.4" }, { name = "pynacl", specifier = ">=1.5.0" }, { name = "pypdf2", specifier = ">=3.0.1" }, - { name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" }, + { name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.4.0.post1" }, { name = "python-docx", specifier = ">=1.1.0" }, { name = "python-multipart", specifier = ">=0.0.27" }, { name = "python-socks", specifier = ">=2.7.1" }, @@ -3297,7 +3299,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3336,7 +3338,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3348,7 +3350,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3378,9 +3380,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3392,7 +3394,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4482,21 +4484,18 @@ crypto = [ [[package]] name = "pylibseekdb" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" }, - { url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" }, - { url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" }, - { url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" }, - { url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" }, + { url = "https://files.pythonhosted.org/packages/64/93/e9a13b996b5561f89c9a4f1b62796f8a6230a5dce215869e3cfef8adc4f1/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e37b931417b7fc7fc88d15fd8b9b0dad499cd05e693cc483aa3743840e85f0c0", size = 49442143, upload-time = "2026-08-27T13:03:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/e54bb304512042cac0514fd607175f5425bd0924330e3eb74937c2afe827/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6aaa3c9e4865d32f533af04eb8eab06d2c10fc581ac38097d618efb44c05dc5b", size = 53975703, upload-time = "2026-08-27T13:04:41.008Z" }, + { url = "https://files.pythonhosted.org/packages/ca/03/4380094699cbd4539971c0b943701f776408c94c28cc3ecdaed7c217bb29/pylibseekdb-1.4.0-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:2fee55af299f2992dd5d61c9e239ef8855629f4117ee8b4c21dc877160707004", size = 52171602, upload-time = "2026-08-27T13:05:15.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/ea71acbee58925a51cb1a7137afd2d1c4e3fbb5bf2a0144cd53d299a20df/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f606579904a19bcd7ec96bc117251db9d4202485fc7355f2a289804d1b3b2c1b", size = 49438937, upload-time = "2026-08-27T13:03:48.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f3/452485e45676a7d738720a7a3f7abbf4d24a3d272cbacabef41ae8b8e52c/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d32d6f0d3b92b0c719b6c4230a24748ce10666f67052c696359e69138d8fbe7", size = 53972507, upload-time = "2026-08-27T13:04:46.946Z" }, ] [[package]] @@ -4621,7 +4620,7 @@ wheels = [ [[package]] name = "pyseekdb" -version = "1.1.0.post3" +version = "1.4.0.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", marker = "python_full_version < '3.14'" }, @@ -4635,7 +4634,7 @@ dependencies = [ { name = "tqdm", marker = "python_full_version < '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/87/d5dd862faa3d4adf3847c1ce19c3ea5ecd0dcfda9c2584a95bfd2b0fac0f/pyseekdb-1.4.0.post1-py3-none-any.whl", hash = "sha256:a3379f6962a0c01aa029d3e5a8f0c0f5a59b27a689b8aae1931d9ce5563f252c", size = 158375, upload-time = "2026-08-03T08:56:59.501Z" }, ] [[package]] @@ -5256,10 +5255,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5306,7 +5305,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5377,14 +5376,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "torch", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "transformers", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5757,21 +5756,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.14'" }, + { name = "fsspec", marker = "python_full_version >= '3.14'" }, + { name = "jinja2", marker = "python_full_version >= '3.14'" }, + { name = "networkx", marker = "python_full_version >= '3.14'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.14'" }, + { name = "sympy", marker = "python_full_version >= '3.14'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5813,15 +5812,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "packaging", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "regex", marker = "python_full_version >= '3.14'" }, + { name = "safetensors", marker = "python_full_version >= '3.14'" }, + { name = "tokenizers", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "typer", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -6082,9 +6081,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "protobuf" }, - { name = "sniffio" }, + { name = "anyio", marker = "sys_platform != 'win32'" }, + { name = "protobuf", marker = "sys_platform != 'win32'" }, + { name = "sniffio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From 9b130680ca6d60b43ce592c6ee90a6bff568b965 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 13 Sep 2026 00:39:21 +0800 Subject: [PATCH 48/56] ci(discord): announce published stable releases via webhook (#2539) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .github/discord-release/README.md | 111 ++++++ .github/discord-release/announce.py | 162 +++++++++ .github/discord-release/test_announce.py | 427 +++++++++++++++++++++++ .github/workflows/discord-release.yml | 64 ++++ 4 files changed, 764 insertions(+) create mode 100644 .github/discord-release/README.md create mode 100644 .github/discord-release/announce.py create mode 100644 .github/discord-release/test_announce.py create mode 100644 .github/workflows/discord-release.yml diff --git a/.github/discord-release/README.md b/.github/discord-release/README.md new file mode 100644 index 000000000..21deee164 --- /dev/null +++ b/.github/discord-release/README.md @@ -0,0 +1,111 @@ +# Discord release announcements + +This independent workflow announces new stable LangBot releases in the channel +selected by a dedicated Discord incoming webhook. It does not change the existing +release/build workflows, edit releases, run a persistent service, poll, or backfill. +Announcements run on publication, independently of artifact builds finishing. + +## Setup and read-only validation + +1. In the intended community **announcement channel**, create a dedicated incoming + webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse + a webhook belonging to another automation. +2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the + **repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly + `https://discord.com/api/webhooks//` — no query, trailing slash, + API-version segment, or alternate domain. Treat the entire URL as a password. +3. Once this workflow is on `master`, open Actions → **Discord Release Announcement** + → Run workflow, choosing `master`. Alternatively: + + ```sh + gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master + ``` + +4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1` + and reports `guild_id` and `channel_id`; compare both with the intended server + and channel using Discord Developer Mode → Copy ID. The secret determines the + destination; no channel ID is guessed or overridden. The URL/token is never + logged. Dispatch cannot send a test message or announce an old release, even + when run again. Missing/invalid secrets fail validation clearly; offline tests + do not need secrets. + +GET validation confirms the webhook's identity, not delivery or notification +permissions. Verify those on the first genuine release. `mention_everyone=true` +confirms Discord parsed the mention; it cannot prove every member received a push +notification (member/server notification settings still apply). + +## Activation and message + +The workflow and `.github/discord-release/` helper **must be in the commit targeted +by each new release tag**. Merging to `master` does not enable announcements for +old tags whose commits lack these files. Manual dispatch becomes available when +the workflow is on the default branch. Only publish release tags from trusted, +reviewed commits: release workflows execute that tag's code with the secret. + +Only `release` events with action `published`, `draft=false`, and +`prerelease=false` can send. Drafts and prereleases are skipped; release edits do +not trigger announcements. The helper requires the repository to be exactly +`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters), +and its exact canonical GitHub release URL. Other naming schemes fail closed. + +Example message (the version and URL come from the validated event file): + +```text +@everyone LangBot v4.10.11 is now available! +Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11 +``` + +The release title/body is never copied. There is one literal `@everyone`, explicit +`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply +mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS +only to `discord.com`, an explicit User-Agent, and no redirects or automatic +retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID; +an exact `/messages/` GET verifies its ID, webhook/channel, content, +`mention_everyone=true`, and empty user/role mention arrays before success. + +## Repeat guard and manual recovery + +Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun +(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation, +even if the first attempt failed before sending. Read-only dispatch may be rerun. + +This is a practical repeat guard, **not durable exactly-once delivery**. It cannot +prevent duplicates from a separate new run/event (for example deleting/recreating +a release), separate automation, or manual posting. It stores no durable dedupe +state and never modifies the release to mark delivery. + +If a POST times out, returns an error, or readback fails, the message may already +exist. The workflow fails rather than blindly sending again. A returned message ID +is included in the safe error when available. A runner termination can also leave +an ambiguous send without that log line. + +1. Inspect the announcement channel and the failed run logs. Locate the canonical + release link and, if available, the returned message ID. A failed verification + does **not** mean the message was absent. +2. If present, reconcile the existing message/mention problem manually; do not + rerun, create another release event, or send a duplicate ping. +3. If an operator has positively confirmed no message exists, fix the secret or + permission issue and use read-only dispatch to validate configuration. A + maintainer may then post the announcement manually once in Discord and record + the message link in the incident/run notes. Do not override the attempt guard + or delete/recreate a release to force recovery. +4. If absence cannot be established, pause and reconcile rather than resending. + +To stop future sends, disable **Discord Release Announcement** in Actions. Rotate +or delete the dedicated Discord webhook if the URL is exposed, and update the +secret before validation. No rollback of release artifacts is involved. + +## Local checks + +Requires Python 3.11+ and the standard library only: + +```sh +python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v +python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py +``` + +Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs, +HTTP failures, exact message readback, and refusal to retry. Only the HTTPS +transport is mocked for Discord tests; no live Discord requests or messages are +made. Changes to this directory or its workflow run the offline tests on push and +pull request; tests also gate release sending and read-only dispatch validation. diff --git a/.github/discord-release/announce.py b/.github/discord-release/announce.py new file mode 100644 index 000000000..c8548d380 --- /dev/null +++ b/.github/discord-release/announce.py @@ -0,0 +1,162 @@ +"""Announce only first-attempt stable releases; dispatch is read-only validation.""" + +import http.client +import json +import os +from pathlib import Path +import re +import sys + +REPOSITORY = 'langbot-app/LangBot' +RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/' +RECONCILE = ( + 'Do not resend or bypass the run-attempt guard; manual reconciliation is required. ' + 'Inspect the announcement channel and workflow logs before any manual recovery ' + '(see .github/discord-release/README.md).' +) + + +class AnnouncementError(Exception): + """A safe, operator-facing error containing no webhook URL or response body.""" + + +def release_payload(event, attempt): + """Return a bounded, mention-safe payload, or None for draft/preview releases.""" + if not isinstance(event, dict) or event.get('action') != 'published': + raise AnnouncementError('Only release.published events are accepted.') + repository = event.get('repository') + if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY: + raise AnnouncementError('Unexpected release repository.') + release = event.get('release') + if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')): + raise AnnouncementError('Invalid release flags.') + if release['draft'] or release['prerelease']: + return None + if attempt != '1': + raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}') + tag = release.get('tag_name') + if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag): + raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).') + url = RELEASE_PREFIX + tag + if release.get('html_url') != url: + raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.') + return { + 'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}', + 'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False}, + 'tts': False, + 'flags': 0, + } + + +def is_snowflake(value): + return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None + + +class DiscordWebhook: + def __init__(self, url): + if not url: + raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.') + match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url) + if not match: + raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks//.') + self.path, self.id = match.groups() + + def _request(self, method, suffix='', payload=None): + # Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery. + connection = http.client.HTTPSConnection('discord.com', timeout=20) + try: + body = json.dumps(payload).encode('utf-8') if payload is not None else None + connection.request( + method, + self.path + suffix, + body=body, + headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'}, + ) + response = connection.getresponse() + if response.status != 200: + raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.') + raw = response.read(1_048_577) + if len(raw) > 1_048_576: + raise AnnouncementError('Discord response exceeded the size limit.') + return json.loads(raw) + except (OSError, http.client.HTTPException, ValueError, UnicodeError): + # Exceptions and bodies can contain the token; never print them or chain them. + raise AnnouncementError( + f'Discord {method} failed or returned invalid JSON; no retry was attempted.' + ) from None + finally: + connection.close() + + def validate(self): + """GET only: verify an incoming webhook and return safe identifying fields.""" + webhook = self._request('GET') + if ( + not isinstance(webhook, dict) + or type(webhook.get('type')) is not int + or webhook['type'] != 1 + or webhook.get('id') != self.id + or not is_snowflake(webhook.get('guild_id')) + or not is_snowflake(webhook.get('channel_id')) + ): + raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.') + return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')} + + def send(self, payload): + """One POST, followed by exact message GET; never automatically retry a send.""" + webhook = self.validate() + message_id = None + try: + sent = self._request('POST', '?wait=true', payload) + if not isinstance(sent, dict) or not is_snowflake(sent.get('id')): + raise AnnouncementError('Discord did not return a valid message ID.') + message_id = sent['id'] + saved = self._request('GET', f'/messages/{message_id}') + if ( + not isinstance(saved, dict) + or saved.get('id') != message_id + or saved.get('webhook_id') != self.id + or saved.get('channel_id') != webhook['channel_id'] + or saved.get('content') != payload['content'] + or saved.get('mention_everyone') is not True + or saved.get('mentions') != [] + or saved.get('mention_roles') != [] + ): + raise AnnouncementError('Discord message readback did not match content, identity, or mentions.') + except AnnouncementError as error: + reference = f' Returned message ID: {message_id}.' if message_id else '' + raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None + return message_id + + +def main(env=None): + env = os.environ if env is None else env + try: + if env.get('GITHUB_REPOSITORY') != REPOSITORY: + raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.') + name = env.get('GITHUB_EVENT_NAME') + if name == 'workflow_dispatch': + webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate() + print( + f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.' + ) + return 0 + if name != 'release': + raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.') + try: + event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8')) + except (OSError, ValueError, UnicodeError): + raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None + payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT')) + if payload is None: + print('Skipped draft or prerelease; no message sent.') + return 0 + message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload) + print(f'Announcement verified by exact message readback: message_id={message_id}.') + return 0 + except AnnouncementError as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/discord-release/test_announce.py b/.github/discord-release/test_announce.py new file mode 100644 index 000000000..f77bae8b0 --- /dev/null +++ b/.github/discord-release/test_announce.py @@ -0,0 +1,427 @@ +"""Offline contract tests; no Discord credentials or network required.""" + +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +try: + import announce +except ModuleNotFoundError: + announce = None + +WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY' +WEBHOOK_ID = '123456789012345678' +GUILD_ID = '234567890123456789' +CHANNEL_ID = '345678901234567890' +MESSAGE_ID = '456789012345678901' +REPO = 'langbot-app/LangBot' +URL = f'https://github.com/{REPO}/releases/tag/v4.10.11' +CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}' + + +def event(): + return { + 'action': 'published', + 'repository': {'full_name': REPO}, + 'release': { + 'draft': False, + 'prerelease': False, + 'tag_name': 'v4.10.11', + 'html_url': URL, + 'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)', + 'body': '@everyone @here <@123> <@&456> `hostile`', + }, + } + + +def metadata(): + return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID} + + +def message(): + return { + 'id': MESSAGE_ID, + 'webhook_id': WEBHOOK_ID, + 'channel_id': CHANNEL_ID, + 'content': CONTENT, + 'mention_everyone': True, + 'mentions': [], + 'mention_roles': [], + } + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(announce, 'The release announcement helper must exist') + + +class PolicyTests(BaseTest): + def test_payload_has_one_literal_everyone_and_no_untrusted_body(self): + payload = announce.release_payload(event(), '1') + self.assertEqual(payload['content'], CONTENT) + self.assertEqual(json.dumps(payload).count('@everyone'), 1) + self.assertEqual( + payload['allowed_mentions'], + { + 'parse': ['everyone'], + 'users': [], + 'roles': [], + 'replied_user': False, + }, + ) + self.assertIs(payload['tts'], False) + self.assertEqual(payload['flags'], 0) + + def test_drafts_and_prereleases_are_skipped(self): + for flag in ('draft', 'prerelease'): + with self.subTest(flag=flag): + value = event() + value['release'][flag] = True + self.assertIsNone(announce.release_payload(value, '1')) + + def test_only_published_action_is_accepted(self): + for action in ('edited', 'created', 'released', 'deleted', '', None): + with self.subTest(action=action): + value = event() + value['action'] = action + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self): + for attempt in ('2', '3', '', None, '01', '0', '1\n'): + with self.subTest(attempt=attempt): + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.release_payload(event(), attempt) + + def test_repository_must_match_exactly(self): + for repo in ('evil/LangBot', 'langbot-app/langbot', None): + value = event() + value['repository']['full_name'] = repo + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_hostile_and_noncanonical_tags_are_rejected(self): + for tag in ( + 'v1.2.3 @everyone', + 'v1.2.3\n', + 'v1.2.3/../../x', + 'v1.2.3?x=y', + '$(id)', + 'v1.2.3-rc.1', + 'v1.2.3', + 'v1.2.3%0a', + '<@123>', + 'v1.2.' + '3' * 100, + '', + None, + 123, + ): + with self.subTest(tag=tag): + value = event() + value['release']['tag_name'] = tag + value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}' + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_release_url_must_be_canonical_and_match_tag(self): + for url in ( + 'https://evil.example/tag/v4.10.11', + URL + '?x=y', + URL + '#anchor', + URL + '/', + URL.replace('v4.10.11', 'v4.10.12'), + URL.replace('github.com', 'github.com@evil.example'), + URL.replace('https:', 'http:'), + URL + '\n', + None, + ): + with self.subTest(url=url): + value = event() + value['release']['html_url'] = url + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_malformed_events_fail_closed(self): + for value in (None, [], {}, {'release': []}, {'repository': None}): + with self.subTest(value=value): + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + for flag in ('draft', 'prerelease'): + for bad in (None, 'false', 0, 1): + value = event() + value['release'][flag] = bad + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + +class DiscordTests(BaseTest): + def setUp(self): + super().setUp() + self.patch = patch('announce.http.client.HTTPSConnection') + self.connection_class = self.patch.start() + self.addCleanup(self.patch.stop) + self.connection = self.connection_class.return_value + + def respond(self, *values): + responses = [] + for value in values: + response = MagicMock() + response.status = 200 + response.read.return_value = json.dumps(value).encode() + responses.append(response) + self.connection.getresponse.side_effect = responses + + def methods(self): + return [call.args[0] for call in self.connection.request.call_args_list] + + def test_webhook_validation_is_get_only_and_reports_ids(self): + self.respond(metadata()) + result = announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(result, metadata()) + self.assertEqual(self.methods(), ['GET']) + self.assertEqual( + self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY') + ) + self.connection_class.assert_called_with('discord.com', timeout=20) + self.connection.close.assert_called_once() + + def test_invalid_webhook_urls_are_rejected_before_network(self): + for url in ( + '', + None, + WEBHOOK + '/', + WEBHOOK + '?wait=true', + WEBHOOK + '#x', + WEBHOOK + '\n', + ' ' + WEBHOOK, + WEBHOOK.replace('https:', 'http:'), + WEBHOOK.replace('discord.com', 'discord.com.evil.example'), + WEBHOOK.replace('discord.com', 'discord.com@evil.example'), + WEBHOOK.replace('discord.com', 'discord.com:443'), + WEBHOOK.replace('/api/', '/api/v10/'), + WEBHOOK.replace(WEBHOOK_ID, 'abc'), + WEBHOOK + '/../../x', + WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'), + ): + with self.subTest(url=url): + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(url) + self.connection_class.assert_not_called() + + def test_webhook_metadata_requires_incoming_type_and_ids(self): + invalid = [ + None, + [], + {}, + dict(metadata(), type=2), + dict(metadata(), type=True), + dict(metadata(), id='999'), + dict(metadata(), channel_id=None), + dict(metadata(), guild_id='::error::hostile'), + ] + for value in invalid: + with self.subTest(value=value): + self.respond(value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('POST', self.methods()) + + def test_send_waits_and_reads_back_exact_returned_message(self): + self.respond(metadata(), message(), message()) + result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(result, MESSAGE_ID) + self.assertEqual(self.methods(), ['GET', 'POST', 'GET']) + calls = self.connection.request.call_args_list + self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true')) + self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1')) + self.assertEqual( + calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}') + ) + + def test_readback_must_match_content_mentions_and_identity(self): + for field, bad in ( + ('content', 'wrong'), + ('mention_everyone', False), + ('mention_everyone', 1), + ('mentions', [{'id': '123'}]), + ('mention_roles', ['123']), + ('id', '999'), + ('channel_id', '999'), + ('webhook_id', '999'), + ): + with self.subTest(field=field, bad=bad): + self.connection.reset_mock() + self.respond(metadata(), message(), dict(message(), **{field: bad})) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods().count('POST'), 1) + + def test_missing_readback_fields_fail_closed(self): + for field in message(): + value = message() + del value[field] + self.respond(metadata(), message(), value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + + def test_unsafe_post_message_id_never_becomes_get_path(self): + for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')): + self.connection.reset_mock() + self.respond(metadata(), value) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_post_failure_never_retries_and_never_logs_secret(self): + for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204): + with self.subTest(status=status): + self.connection.reset_mock() + self.respond(metadata(), message()) + responses = list(self.connection.getresponse.side_effect) + responses[1].status = status + self.connection.getresponse.side_effect = responses + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_ambiguous_timeout_never_retries_or_echoes_exception(self): + self.respond(metadata()) + first = next(self.connection.getresponse.side_effect) + self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)] + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_malformed_json_response_is_sanitized(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.read.return_value = WEBHOOK.encode() + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError) as caught: + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('fixture_token', str(caught.exception)) + + def test_get_redirect_is_not_followed(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.status = 302 + response.getheader.return_value = 'https://evil.example/' + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(self.methods(), ['GET']) + + +class EntrypointTests(BaseTest): + def run_main(self, data=None, **overrides): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + path.write_text(json.dumps(event() if data is None else data)) + env = { + 'GITHUB_EVENT_NAME': 'release', + 'GITHUB_EVENT_PATH': str(path), + 'GITHUB_REPOSITORY': REPO, + 'GITHUB_RUN_ATTEMPT': '1', + 'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK, + } + env.update(overrides) + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + result = announce.main(env) + return result, output.getvalue() + + def test_dispatch_only_validates_even_if_event_contains_release(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.validate.return_value = metadata() + result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch') + self.assertEqual(result, 0) + client.return_value.validate.assert_called_once() + client.return_value.send.assert_not_called() + self.assertIn(GUILD_ID, output) + self.assertIn(CHANNEL_ID, output) + self.assertNotIn('fixture_token', output) + + def test_production_release_sends_once(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.send.return_value = MESSAGE_ID + result, output = self.run_main() + self.assertEqual(result, 0) + client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1')) + self.assertIn(MESSAGE_ID, output) + + def test_skipped_releases_need_no_secret_or_network(self): + for flag in ('draft', 'prerelease'): + value = event() + value['release'][flag] = True + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 0) + client.assert_not_called() + + def test_rerun_never_constructs_client(self): + with patch('announce.DiscordWebhook') as client: + result, output = self.run_main(GITHUB_RUN_ATTEMPT='2') + self.assertEqual(result, 1) + self.assertIn('manual reconciliation', output) + client.assert_not_called() + + def test_unexpected_event_or_repository_cannot_send(self): + for overrides in ( + {'GITHUB_EVENT_NAME': 'push'}, + {'GITHUB_EVENT_NAME': 'pull_request'}, + {'GITHUB_REPOSITORY': 'evil/LangBot'}, + ): + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(**overrides) + self.assertEqual(result, 1) + client.assert_not_called() + + def test_missing_secret_fails_clearly_for_send_and_validation(self): + for name in ('release', 'workflow_dispatch'): + result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 1) + self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output) + + def test_cli_reads_event_file_and_redacts_invalid_input(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + value = event() + value['release']['tag_name'] = '::error::hostile @everyone' + path.write_text(json.dumps(value)) + env = dict( + os.environ, + GITHUB_EVENT_NAME='release', + GITHUB_EVENT_PATH=str(path), + GITHUB_REPOSITORY=REPO, + GITHUB_RUN_ATTEMPT='1', + DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK, + ) + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name('announce.py'))], + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertNotIn('hostile', result.stderr) + self.assertNotIn('fixture_token', result.stderr) + self.assertNotIn('Traceback', result.stderr) + + def test_unreadable_event_fails_safely(self): + result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json') + self.assertEqual(result, 1) + self.assertNotIn('Traceback', output) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml new file mode 100644 index 000000000..23e5d8033 --- /dev/null +++ b/.github/workflows/discord-release.yml @@ -0,0 +1,64 @@ +name: Discord Release Announcement + +on: + release: + types: [published] + workflow_dispatch: + push: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + pull_request: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + +permissions: + contents: read + +jobs: + tests: + name: Offline announcement tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Test helper without secrets or network + run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v + + validate: + name: Validate webhook (GET only, no message) + if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch' + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Validate incoming webhook and report guild/channel IDs + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py + + announce: + name: Announce published stable release + if: >- + github.repository == 'langbot-app/LangBot' && + github.event_name == 'release' && github.event.action == 'published' && + github.event.release.draft == false && github.event.release.prerelease == false + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + # The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance. + # Never interpolate release data into a shell command. + - name: Send once and verify the exact Discord message + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py From 940895c541e03d8febdfff8877d769a46e0645ca Mon Sep 17 00:00:00 2001 From: hedging8563 Date: Sun, 13 Sep 2026 21:24:38 +0800 Subject: [PATCH 49/56] chore(brand): refresh TokenLab logo --- .../pkg/provider/modelmgr/requesters/tokenlab.svg | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tokenlab.svg b/src/langbot/pkg/provider/modelmgr/requesters/tokenlab.svg index 2308dca3b..6c193a921 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/tokenlab.svg +++ b/src/langbot/pkg/provider/modelmgr/requesters/tokenlab.svg @@ -1,5 +1,8 @@ - - - - + + TokenLab + Specimen Split symbol, positive master for sizes from 32 to 96 pixels. + + + + From 60bb67f0256d10320f2ce8900bf704eb2a8d3146 Mon Sep 17 00:00:00 2001 From: sheetung <30528385+sheetung@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:31:19 +0800 Subject: [PATCH 50/56] Fnos packaging (#2524) * feat(packaging): add fnOS FPK packaging and CI workflow Add packaging/fnos/ shell (manifest, lifecycle cmd scripts, install/ upgrade/uninstall wizards, desktop entry, EULA) plus in-repo build script. A release now auto-builds langbot--fnos.fpk via .github/workflows/build-fnos-fpk.yaml, uploaded to the release assets. * ci(fnos): skip release upload on manual dispatch github.event.release.tag_name is empty when triggered via workflow_dispatch, causing 'gh release upload' to fail with 'requires at least 2 arg(s)'. Restrict the step to release events. * ci(fnos): normalize release asset name Strip a trailing -fnos from the tag-derived version before appending the suffix, avoiding langbot--fnos-fnos.fpk when the tag itself already carries -fnos. * ci(fnos): use release tag version for auto build, manifest for manual Auto build (release/tag) reads version from tag like other release workflows; build.sh strips the v prefix when injecting into manifest. Manual dispatch falls back to the version maintained in manifest. * feat(fnos): add post-install deployment notice to install wizard Last wizard step now informs users that first startup takes about 5-10 minutes for dependency setup before the web UI is ready. * docs(fnos): add packaging directory README * refactor(fnos): rename app from ai.langbot to langbot Rename appname, desktop entry, data share, build artifacts, and all references from ai.langbot to langbot across packaging files. --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .github/workflows/build-fnos-fpk.yaml | 78 ++++++++ .gitignore | 9 + packaging/fnos/LICENSE | 145 +++++++++++++++ packaging/fnos/README.md | 78 ++++++++ packaging/fnos/app/desktop/langbot.main.url | 9 + packaging/fnos/app/ui/config | 13 ++ packaging/fnos/build.sh | 180 ++++++++++++++++++ packaging/fnos/cmd/config_callback | 6 + packaging/fnos/cmd/config_init | 4 + packaging/fnos/cmd/install_callback | 149 +++++++++++++++ packaging/fnos/cmd/install_init | 5 + packaging/fnos/cmd/main | 194 ++++++++++++++++++++ packaging/fnos/cmd/uninstall_callback | 21 +++ packaging/fnos/cmd/uninstall_init | 20 ++ packaging/fnos/cmd/upgrade_callback | 77 ++++++++ packaging/fnos/cmd/upgrade_init | 20 ++ packaging/fnos/config/privilege | 5 + packaging/fnos/config/resource | 9 + packaging/fnos/manifest | 16 ++ packaging/fnos/wizard/install | 47 +++++ packaging/fnos/wizard/uninstall | 17 ++ packaging/fnos/wizard/upgrade | 38 ++++ 22 files changed, 1140 insertions(+) create mode 100644 .github/workflows/build-fnos-fpk.yaml create mode 100644 packaging/fnos/LICENSE create mode 100644 packaging/fnos/README.md create mode 100644 packaging/fnos/app/desktop/langbot.main.url create mode 100644 packaging/fnos/app/ui/config create mode 100644 packaging/fnos/build.sh create mode 100755 packaging/fnos/cmd/config_callback create mode 100755 packaging/fnos/cmd/config_init create mode 100755 packaging/fnos/cmd/install_callback create mode 100755 packaging/fnos/cmd/install_init create mode 100755 packaging/fnos/cmd/main create mode 100755 packaging/fnos/cmd/uninstall_callback create mode 100755 packaging/fnos/cmd/uninstall_init create mode 100755 packaging/fnos/cmd/upgrade_callback create mode 100755 packaging/fnos/cmd/upgrade_init create mode 100644 packaging/fnos/config/privilege create mode 100644 packaging/fnos/config/resource create mode 100644 packaging/fnos/manifest create mode 100644 packaging/fnos/wizard/install create mode 100644 packaging/fnos/wizard/uninstall create mode 100644 packaging/fnos/wizard/upgrade diff --git a/.github/workflows/build-fnos-fpk.yaml b/.github/workflows/build-fnos-fpk.yaml new file mode 100644 index 000000000..e24906e39 --- /dev/null +++ b/.github/workflows/build-fnos-fpk.yaml @@ -0,0 +1,78 @@ +name: Build fnOS FPK + +on: + workflow_dispatch: + ## 发布release的时候会自动构建 + release: + types: [published] + +permissions: + contents: write + +jobs: + build-fnos-fpk: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Check version + id: check_version + run: | + echo $GITHUB_REF + # 如果是tag,则去掉refs/tags/前缀(与其他 release workflow 一致,版本号取 tag 名) + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "It's a tag" + echo "version=$(echo $GITHUB_REF | awk -F '/' '{print $3}')" >> $GITHUB_OUTPUT + else + # 手动触发(workflow_dispatch):读不到 tag,使用 manifest 内维护的版本 + echo "It's not a tag" + echo "version=$(grep '^version=' packaging/fnos/manifest | cut -d= -f2)" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: '22' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: | + pip install pillow + # fnpack:飞牛官方打包 CLI(静态二进制) + curl -fsSL -o /usr/local/bin/fnpack \ + https://static2.fnnas.com/fnpack/fnpack-1.2.3-linux-amd64 + chmod +x /usr/local/bin/fnpack + + - name: Build FPK + env: + FPK_VERSION: ${{ steps.check_version.outputs.version }} + run: | + bash packaging/fnos/build.sh + test -f packaging/fnos/langbot.fpk + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: langbot-${{ steps.check_version.outputs.version }}-fnos + path: packaging/fnos/langbot.fpk + + - name: Upload To Release + # 仅 release 触发时执行;手动/workflow_dispatch 触发时没有 release, + # 且 github.event.release.tag_name 为空(否则 gh release upload 缺参数报错) + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # tag 可能已带 -fnos 后缀(如 v4.10.9-fnos),产物名统一为 + # langbot-<基础版本>-fnos.fpk,避免出现 -fnos-fnos + VER="${{ steps.check_version.outputs.version }}" + BASE="${VER#v}"; BASE="${BASE%-fnos}" + cp packaging/fnos/langbot.fpk "langbot-${BASE}-fnos.fpk" + gh release upload ${{ github.event.release.tag_name }} "langbot-${BASE}-fnos.fpk" diff --git a/.gitignore b/.gitignore index db632fb19..459a22c82 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,12 @@ web/.next/ web/.pnpm-home .tmp Caddyfile + +# fnOS packaging build artifacts (packaging/fnos/build.sh) +packaging/fnos/app/langbot/ +packaging/fnos/app/bin/ +packaging/fnos/ICON.PNG +packaging/fnos/ICON_256.PNG +packaging/fnos/app/ui/images/ +packaging/fnos/app/desktop/images/ +packaging/fnos/*.fpk diff --git a/packaging/fnos/LICENSE b/packaging/fnos/LICENSE new file mode 100644 index 000000000..e297f1789 --- /dev/null +++ b/packaging/fnos/LICENSE @@ -0,0 +1,145 @@ + + LangBot 用户许可协议 + User License Agreement + + 飞牛 fnOS 平台发行版 | 最后更新:2026 年 8 月 + +感谢您使用 LangBot!本协议是您(用户)与 LangBot 开源项目(以下简称「LangBot」「我们」)之间,就您在飞牛 fnOS 平台(含飞牛 NAS 设备、飞牛 OS 及其应用中心,以下简称「平台」)上安装、运行、使用 LangBot 应用所订立的合法协议。 + +您一旦在飞牛应用中心勾选「我接受许可协议的条款」并继续安装、或以其他方式运行 LangBot,即表示您已阅读、理解并同意本协议的全部内容。如您不同意本协议,请不要安装或使用本软件。 + +--- + +一、软件性质 + +1.1 LangBot 是一款基于 LLM 的多平台智能对话机器人开源软件。主程序源代码按照 Apache License, Version 2.0 公开,您可在遵守开源协议的前提下自由使用、修改与再分发。 + +1.2 本发行版系 LangBot 社区为飞牛 fnOS 平台打包构建的自托管移植版本,与飞牛官方、飞牛硬件厂商不存在从属或关联关系。飞牛应用中心提供的分发渠道不构成对软件功能、可用性的任何担保。 + +--- + +二、使用授权 + +2.1 授予您一份有限的、非排他的、不可转让的个人使用许可:您可在一台或多台您合法拥有或管理的 fnOS 设备上安装、运行本软件,用于个人、家庭或组织内部合法用途。 + +2.2 您不得: + (a) 将本软件用于违反中国大陆地区法律法规或您所在司法管辖区法律的用途; + (b) 对机器人账号进行骚扰、诈骗、批量营销、发布违法违规内容等滥用行为; + (c) 逆向工程、反编译本软件所包含的第三方二进制(uv 等),但适用法律明确允许或对应开源许可证另作规定的除外; + (d) 试图干扰、过载或损害任何由本软件对外提供的服务或其基础设施。 + +--- + +三、服务可用性与「现状」提供 + +3.1 我们努力提供稳定可靠的软件,但**不保证运行无中断或无错误**。软件可能因维护、升级、不可抗力或其他原因暂时不可用。 + +3.2 本软件按「现状」「按可用」提供。我们不作任何明示或默示担保,包括但不限于对适销性、特定用途适用性与非侵权性的默示担保。我们不保证: + (a) 软件将满足您的具体需求; + (b) 运行不间断、及时、安全或无错误; + (c) 使用获得的结果准确或可靠; + (d) 任何错误都会被修复。 + +--- + +四、责任限制 + +在适用法律允许的最大范围内: +(a) 我们不对任何**间接、附带、特殊、惩罚性或后果性损失**承担责任,包括但不限于利润损失、数据丢失、商誉损失、业务中断或其他无形损失,无论是否已被告知该等损害发生的可能性; +(b) 无论基于合同、侵权、严格责任或其他任何理论,我们就本软件所引起的所有索赔,向您承担的**累计赔偿总额**不超过 15 美元(或等值当地货币)。 + +--- + +五、用户责任 + +5.1 您对通过本软件发送的所有内容和消息(包括您所配置并运行的机器人发出的消息)承担全部责任。 + +5.2 您必须遵守所有适用的法律法规,包括但不限于数据保护、隐私、消费者保护和反垃圾邮件法律。 + +5.3 您有责任保管好自己的账号凭据和各类 API Key,并**自行对重要数据进行备份**。我们对因服务中断、账号终止或系统故障等任何原因造成的数据丢失不承担责任。 + +5.4 您不得将本软件用于任何非法活动、发送垃圾信息、骚扰他人或侵害他人合法权益。 + +5.5 您使用机器人接入任何即时通讯平台(QQ、微信、飞书、钉钉、Telegram 等)前,须自行确认已获得该平台授权并遵守其开发者协议与社区规范;因违规接入导致的账号封禁、平台处罚,由您自行承担。 + +--- + +六、数据与隐私 + +6.1 本自托管版本默认情况下,所有配置、对话记录、知识库与插件数据均保存在您所安装的 fnOS 设备本地共享目录(langbot/data)中,不会被自动上传至除您显式配置的模型/服务提供商以外的任何第三方。您对自己的数据及备份负责。 + +6.2 LangBot 默认启用最少量的匿名遥测,用于帮助改进产品。详细政策见官方文档: + https://docs.langbot.app/zh/insight/data-collection-policy + +6.3 启用遥测时仅可能发送:查询事件(适配器类型、运行器类型、模型名称、处理耗时、版本号、匿名工作区 UUID、插件/功能使用计数、不含用户内容的错误追踪)、每日一次的工作区心跳(部署概况、资源对象数量)、完全自愿的问卷回答。 + +6.4 我们绝对不收集:消息内容、用户名/手机号/平台账号 ID、API 密钥或凭据、IP 地址、文件或媒体内容。 + +6.5 关闭方式:进入 LangBot Web 管理界面 → 设置 → Space 遥测,关闭开关;或在配置文件 data/config.yaml 中设置 `space.disable_telemetry: true`。关闭后所有功能照常运行。 + +--- + +七、不可抗力 + +因不可抗力事件导致的履约失败或延迟,我们不承担责任。不可抗力包括但不限于:自然灾害(地震、洪水、飓风等)、战争、恐怖主义或内乱、政府行为或法规、网络攻击(DDoS、勒索软件等)、第三方服务故障(AI 模型提供商、即时通讯平台、飞牛平台运行时等)、电力故障或互联网连接中断、流行病或公共卫生紧急事件。 + +--- + +八、第三方服务 + +本软件可能依赖或集成第三方服务,包括但不限于:即时通讯平台(Telegram、Discord、微信、QQ、Slack 等)、AI 模型提供商(OpenAI、Anthropic、Google、深度求索等)、飞牛平台的应用中心运行时与依赖应用(如 Node.js)。我们不对第三方服务的可用性、准确性、可靠性或安全性负责;使用第三方服务须受其各自条款约束,第三方服务的变更可能不经通知即影响本软件功能。 + +--- + +九、软件修改与终止 + +9.1 我们保留随时修改、暂停或终止软件或其中任何部分的权利,无论是否事先通知。 +9.2 我们保留随时修改本协议的权利。对重大变更我们会尽力在项目主页公告,继续使用软件即视为接受修订后的协议。 +9.3 您可以在飞牛应用中心卸载本软件。卸载时向导会询问是否保留数据,您可自主选择。终止后您继续使用软件的权利立即终止,我们无义务保留您的数据。 + +--- + +十、赔偿 + +您同意赔偿、抗辩并使 LangBot 团队及其关联贡献者、管理人员、代理人、员工免受任何及所有因以下事项引起或与之相关的索赔、损失、损害、责任、成本和费用(包括合理的律师费): +(a) 您对软件的使用; +(b) 您违反本协议; +(c) 您违反任何适用的法律或法规; +(d) 您侵犯任何第三方权利; +(e) 您或您所配置的机器人通过本软件传输的内容。 + +--- + +十一、知识产权 + +11.1 本软件及其设计、代码、文档和品牌归 LangBot 项目所有并受知识产权法保护。 +11.2 使用本软件并不授予您对软件的任何所有权。 +11.3 您保留通过本软件创建和传输内容的所有权。 + +--- + +十二、争议解决 + +因本协议引起或与之相关的任何争议,应首先通过友好协商解决。协商在 30 日内未达成一致的,任何一方均可依适用法律规定向有管辖权的法院提起诉讼。 + +--- + +十三、可分割性 + +如本协议的任何条款被认定为不可执行或无效,该条款应在最小必要范围内予以限制或剔除,其余条款继续完全有效。 + +--- + +十四、完整协议 + +本协议连同我们的数据收集政策(https://docs.langbot.app/zh/insight/data-collection-policy)构成您与我们之间关于本软件的完整协议,并取代所有先前的协议与谅解。 + +--- + +附录:开源许可证声明 +LangBot 主程序代码依照 Apache License 2.0 发布。详细条款见: +https://github.com/langbot-app/LangBot/blob/master/LICENSE +或 LangBot 源码包内的 LICENSE 文件。 + +数据收集政策:https://docs.langbot.app/zh/insight/data-collection-policy + diff --git a/packaging/fnos/README.md b/packaging/fnos/README.md new file mode 100644 index 000000000..1bd672b67 --- /dev/null +++ b/packaging/fnos/README.md @@ -0,0 +1,78 @@ +# LangBot fnOS Packaging + +This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities. + +## Directory Structure + +``` +packaging/fnos/ +├── manifest # App metadata (appname/version/port/dependency declarations) +├── build.sh # One-shot build script (shared by local and CI) +├── LICENSE +├── config/ +│ ├── privilege # Privilege config (run-as: root) +│ └── resource # Persistent data share declaration (langbot/data) +├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars) +│ ├── main # Service start/stop manager (start/stop/status, owns PID/log) +│ ├── install_init # Pre-install hook +│ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port +│ ├── upgrade_init # Pre-upgrade hook +│ ├── upgrade_callback # Post-upgrade hook +│ ├── uninstall_init # Pre-uninstall hook +│ ├── uninstall_callback # Post-uninstall hook (keeps data per wizard choice) +│ ├── config_init # Pre-config-change hook +│ └── config_callback # Post-config-change hook (apply new port etc.) +├── wizard/ # Install wizards (JSON forms; values passed as wizard_* env vars) +│ ├── install # On install: Node version, web port, deployment-time notice +│ ├── upgrade # On upgrade: Node version confirmation +│ └── uninstall # On uninstall: whether to keep data +├── app/ +│ ├── ui/config # Desktop entry declaration (${wizard_port} placeholder, substituted by fnOS at install) +│ ├── desktop/langbot.main.url +│ ├── langbot/ # [generated] repo source synced via rsync (includes web/dist) +│ └── bin/ # [generated] offline uv binaries (x86_64/aarch64) +├── ICON.PNG / ICON_256.PNG # [generated] derived from res/logo-blue.png +└── langbot.fpk # [generated] final artifact +``` + +Paths marked `[generated]` are produced by `build.sh`, ignored via `.gitignore`; everything else is a git-tracked source file. + +## Building + +### Locally + +```bash +bash packaging/fnos/build.sh +``` + +Dependencies: python3 + Pillow, node + npm (or pnpm), fnpack (official fnOS packaging CLI, download from https://developer.fnnas.com/docs/cli/fnpack). + +The version comes from `version=` maintained in the manifest; it can also be injected: `FPK_VERSION=4.10.10-1 bash packaging/fnos/build.sh`. + +### CI + +[`.github/workflows/build-fnos-fpk.yaml`](../../.github/workflows/build-fnos-fpk.yaml) triggers automatically on Release publication and uploads `langbot--fnos.fpk` to the Release; it can also be triggered manually via workflow_dispatch. + +Version sources (consistent with the other release workflows): + +| Trigger | Version source | +|---|---| +| Release/tag auto build | Tag name (`v4.10.10-1` → in-package `4.10.10-1`; build.sh strips the `v` prefix on injection) | +| Manual workflow_dispatch / local build | Version maintained in manifest | + +## Final Artifact + +`langbot.fpk` (gzip + tar archive), containing: + +- `manifest` — realigned and appended with a `checksum` field by fnpack +- `app.tgz` — app payload (source, web/dist, uv binaries, entry configs) +- `cmd/`, `config/`, `wizard/` — lifecycle scripts and wizards +- `ICON.PNG`, `ICON_256.PNG`, `LICENSE` + +The first startup after installation takes about 5-10 minutes to finish dependency deployment (uv venv + sync); after that the web admin UI is reachable via the desktop icon or `http://:` (default port 5300). + +## References + +- fnOS developer docs: https://developer.fnnas.com/ +- fnOS app wizard: https://developer.fnnas.com/docs/core-concepts/wizard/ +- fnpack CLI: https://developer.fnnas.com/docs/cli/fnpack diff --git a/packaging/fnos/app/desktop/langbot.main.url b/packaging/fnos/app/desktop/langbot.main.url new file mode 100644 index 000000000..513870fe8 --- /dev/null +++ b/packaging/fnos/app/desktop/langbot.main.url @@ -0,0 +1,9 @@ +{ + "title": "LangBot", + "icon": "images/icon-256.png", + "type": "url", + "protocol": "http", + "port": "${wizard_port}", + "url": "/", + "allUsers": true +} diff --git a/packaging/fnos/app/ui/config b/packaging/fnos/app/ui/config new file mode 100644 index 000000000..d45765dd4 --- /dev/null +++ b/packaging/fnos/app/ui/config @@ -0,0 +1,13 @@ +{ + ".url": { + "langbot.main": { + "title": "LangBot", + "icon": "images/icon-{0}.png", + "type": "url", + "protocol": "http", + "port": "${wizard_port}", + "url": "/", + "allUsers": true + } + } +} diff --git a/packaging/fnos/build.sh b/packaging/fnos/build.sh new file mode 100644 index 000000000..74675e2a3 --- /dev/null +++ b/packaging/fnos/build.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# packaging/fnos/build.sh - Build LangBot fnOS FPK package (in-repo version) +# 在 LangBot 仓库内直接打包飞牛 fnOS 应用 +# Usage: +# bash packaging/fnos/build.sh # 版本取 manifest 中 version= +# FPK_VERSION=4.10.9 bash packaging/fnos/build.sh # 注入版本(CI 用 release tag) +# Dependencies: python3+Pillow, node+npm, fnpack +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # LangBot 仓库根 +FPK_DIR="${SCRIPT_DIR}" + +echo "==> LangBot fnOS FPK builder (in-repo)" +echo " Source root: ${SRC_ROOT}" +echo " FPK dir: ${FPK_DIR}" + +# --- 0. Inject version (release tag) --- +if [ -n "${FPK_VERSION:-}" ]; then + sed -i "s/^version=.*/version=${FPK_VERSION#v}/" "${FPK_DIR}/manifest" +else + # 无注入版本时自动跟进仓库主版本(pyproject.toml) + PY_VER=$(grep -m1 '^version = ' "${SRC_ROOT}/pyproject.toml" | cut -d'"' -f2) + if [ -n "${PY_VER}" ]; then + sed -i "s/^version=.*/version=${PY_VER}/" "${FPK_DIR}/manifest" + fi +fi +MANIFEST_VER=$(grep '^version=' "${FPK_DIR}/manifest" | cut -d= -f2) +echo " FPK version: ${MANIFEST_VER}" + +# --- 1. Build frontend --- +echo "[1/5] Building frontend (web/dist)..." +cd "${SRC_ROOT}/web" +if command -v pnpm >/dev/null 2>&1; then + pnpm install --frozen-lockfile 2>/dev/null || pnpm install + pnpm build +else + npm install + npx vite build +fi +[ -d dist ] || { echo "ERROR: web/dist missing" >&2; exit 1; } +echo " Frontend built" + +# --- 2. Sync source into packaging/fnos/app/langbot/ --- +echo "[2/5] Syncing source to app/langbot/..." +rm -rf "${FPK_DIR}/app/langbot" +mkdir -p "${FPK_DIR}/app/langbot" +cd "${SRC_ROOT}" +rsync -a \ + --exclude='.git' \ + --exclude='.venv' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='web/node_modules' \ + --exclude='web/.vite' \ + --exclude='tests' \ + --exclude='packaging' \ + --exclude='.pytest_cache' \ + --exclude='.mypy_cache' \ + --exclude='.ruff_cache' \ + --exclude='data' \ + --exclude='*.log' \ + --exclude='.dockerignore' \ + --exclude='Dockerfile' \ + --exclude='docker/' \ + --exclude='kubernetes.yaml' \ + --exclude='.github/' \ + --exclude='docs/' \ + --exclude='examples/' \ + --exclude='res/' \ + ./ "${FPK_DIR}/app/langbot/" + +[ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; } +echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))" + +# --- 2.5 Download bundled uv binaries (offline install on NAS) --- +echo "[2.5/5] Downloading bundled uv binaries..." +UV_VERSION="0.12.9" +mkdir -p "${FPK_DIR}/app/bin" +for arch in x86_64 aarch64; do + out="${FPK_DIR}/app/bin/uv-${arch}" + if [ -x "${out}" ]; then + echo " uv-${arch} already present, skip" + continue + fi + tmp="$(mktemp -d)" + if curl -sSL -o "${tmp}/uv.tar.gz" \ + "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \ + && tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \ + && cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then + chmod +x "${out}" + echo " uv-${arch} downloaded (${UV_VERSION})" + else + echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2 + fi + rm -rf "${tmp}" +done + +# --- 3. Regenerate icons from res/logo-blue.png --- +echo "[3/5] Generating icons from res/logo-blue.png..." +export LOGO_SRC="${SRC_ROOT}/res/logo-blue.png" +export OUT_DIR="${FPK_DIR}" +python3 << 'PYEOF' +from PIL import Image +import os, sys + +src = os.environ.get("LOGO_SRC") +out_dir = os.environ.get("OUT_DIR") +if not src or not out_dir: + print("ERROR: LOGO_SRC or OUT_DIR not set", file=sys.stderr) + sys.exit(1) + +if not os.path.isfile(src): + print(f"ERROR: logo source not found: {src}", file=sys.stderr) + sys.exit(1) + +img = Image.open(src).convert("RGBA") + +for size, name in [(64, "ICON.PNG"), (256, "ICON_256.PNG")]: + img.resize((size, size), Image.LANCZOS).save(os.path.join(out_dir, name)) + +ui_dir = os.path.join(out_dir, "app/ui/images") +os.makedirs(ui_dir, exist_ok=True) +for size in [64, 256]: + img.resize((size, size), Image.LANCZOS).save(os.path.join(ui_dir, f"icon-{size}.png")) + +desktop_dir = os.path.join(out_dir, "app/desktop/images") +os.makedirs(desktop_dir, exist_ok=True) +for size in [64, 256]: + img.resize((size, size), Image.LANCZOS).save(os.path.join(desktop_dir, f"icon-{size}.png")) + +print(" Icons generated") +PYEOF + +# --- 4. Validate structure --- +echo "[4/5] Validating FPK structure..." +ERRORS=0 +[ -f "${FPK_DIR}/manifest" ] || { echo " MISSING: manifest"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/config/privilege" ] || { echo " MISSING: config/privilege"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/config/resource" ] || { echo " MISSING: config/resource"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/ICON.PNG" ] || { echo " MISSING: ICON.PNG"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/ICON_256.PNG" ] || { echo " MISSING: ICON_256.PNG"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/ui/config" ] || { echo " MISSING: app/ui/config"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/ui/images/icon-64.png" ] || { echo " MISSING: app/ui/images/icon-64.png"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/ui/images/icon-256.png" ] || { echo " MISSING: app/ui/images/icon-256.png"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/desktop/langbot.main.url" ] || { echo " MISSING: app/desktop/langbot.main.url"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/desktop/images/icon-64.png" ] || { echo " MISSING: app/desktop/images/icon-64.png"; ERRORS=$((ERRORS+1)); } +[ -f "${FPK_DIR}/app/desktop/images/icon-256.png" ] || { echo " MISSING: app/desktop/images/icon-256.png"; ERRORS=$((ERRORS+1)); } +[ -d "${FPK_DIR}/cmd" ] || { echo " MISSING: cmd/"; ERRORS=$((ERRORS+1)); } +[ -d "${FPK_DIR}/wizard" ] || { echo " MISSING: wizard/"; ERRORS=$((ERRORS+1)); } + +for script in "${FPK_DIR}/cmd/"*; do + [ -x "${script}" ] || { echo " NOT EXECUTABLE: cmd/$(basename "$script")"; ERRORS=$((ERRORS+1)); } +done + +if [ "${ERRORS}" -gt 0 ]; then + echo "FAILED: ${ERRORS} validation errors" >&2 + exit 1 +fi +echo " Structure OK" + +# --- 5. Build FPK --- +echo "[5/5] Building .fpk..." +if ! command -v fnpack >/dev/null 2>&1; then + echo "ERROR: fnpack not found in PATH." >&2 + echo " Download from https://developer.fnnas.com/docs/cli/fnpack" >&2 + exit 1 +fi + +cd "${FPK_DIR}" +fnpack build +FPK_FILE=$(ls -t *.fpk 2>/dev/null | head -1) +if [ -n "${FPK_FILE}" ]; then + echo "" + echo "==> Done! FPK: ${FPK_DIR}/${FPK_FILE}" + echo " Size: $(du -sh "${FPK_FILE}" | cut -f1)" +else + echo "WARNING: fnpack finished but no .fpk found in ${FPK_DIR}" >&2 + exit 1 +fi diff --git a/packaging/fnos/cmd/config_callback b/packaging/fnos/cmd/config_callback new file mode 100755 index 000000000..ccecdcdb8 --- /dev/null +++ b/packaging/fnos/cmd/config_callback @@ -0,0 +1,6 @@ +#!/bin/bash +# cmd/config_callback - post-config hook +# Applies wizard-provided settings (e.g. Node.js version) that cmd/main reads. +# Nothing to persist currently; cmd/main reads wizard_node_version at runtime. + +exit 0 diff --git a/packaging/fnos/cmd/config_init b/packaging/fnos/cmd/config_init new file mode 100755 index 000000000..0d366f7a1 --- /dev/null +++ b/packaging/fnos/cmd/config_init @@ -0,0 +1,4 @@ +#!/bin/bash +# cmd/config_init - pre-config hook + +exit 0 diff --git a/packaging/fnos/cmd/install_callback b/packaging/fnos/cmd/install_callback new file mode 100755 index 000000000..4eac06acc --- /dev/null +++ b/packaging/fnos/cmd/install_callback @@ -0,0 +1,149 @@ +#!/bin/bash +# cmd/install_callback - post-install hook +# Prefers bundled uv binary, creates Python venv, syncs deps, verifies dist. +# Also validates the user-selected Node.js version is actually installed. + +APP_DIR="${TRIM_APPDEST}/langbot" + +# --- Resolve data directory --- +# LangBot loads config CWD-relative (data/config.yaml); cmd/main replaces +# APP_DIR/data with a symlink to this persistent dir on every start. +DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" +if [ -z "${DATA_DIR}" ]; then + DATA_DIR="${TRIM_PKGVAR}/data" +fi + +cd "${APP_DIR}" || { + echo "App directory missing after install" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# --- Ensure data directory exists --- +mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true + +# --- Pre-seed config.yaml with the user-selected web port --- +# Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by +# cmd/main at start), so this config survives app upgrades. +# LangBot copies templates/config.yaml there on first boot only if missing, +# so we seed it ourselves with the chosen port. +PORT="${wizard_port:-5300}" +case "${PORT}" in + ''|*[!0-9]*) PORT="5300" ;; +esac +TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml" + +_patch_config() { + local cfg_dir="$1" + local cfg_file="${cfg_dir}/config.yaml" + mkdir -p "${cfg_dir}" 2>/dev/null || true + if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then + cp "${TEMPLATE_FILE}" "${cfg_file}" + fi + if [ -f "${cfg_file}" ]; then + sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${PORT}/" "${cfg_file}" + sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${PORT}'#" "${cfg_file}" + fi +} + +# Seed the persistent dir AND the CWD-relative APP_DIR/data (cmd/main merges +# the latter into the persistent dir via symlink on first start, so the port +# survives regardless of which path ends up being read). +_patch_config "${DATA_DIR}" +if [ "${APP_DIR}/data" != "${DATA_DIR}" ]; then + _patch_config "${APP_DIR}/data" +fi + +# --- Fallback patch for desktop entry port --- +# fnOS natively substitutes ${wizard_port} in ui/config at install time. +# This only kicks in if the placeholder somehow survived (e.g. CLI install). +UI_CONFIG="${TRIM_APPDEST}/ui/config" +if [ -f "${UI_CONFIG}" ] && grep -q 'wizard_port\|{port}' "${UI_CONFIG}"; then + sed -i "s/\${wizard_port}/${PORT}/g; s/{port}/${PORT}/g" "${UI_CONFIG}" +fi + +# --- Validate user-selected Node.js version is installed --- +NODE_VERSION="${wizard_node_version:-22}" +if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then + echo "Node.js v${NODE_VERSION} 未安装:请先在应用中心安装 nodejs_v${NODE_VERSION},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +# --- Python check --- +PYTHON_BIN="python3" +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + PYTHON_BIN="python" +fi +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) +if [ -z "${PY_VER}" ]; then + echo "Python 3.11+ required but not found on this system" > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi +PY_MAJOR=$(echo "${PY_VER}" | cut -d. -f1) +PY_MINOR=$(echo "${PY_VER}" | cut -d. -f2) +if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 11 ]; }; then + echo "Python 3.11+ required, found ${PY_VER}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +# --- Resolve uv: bundled binary first, then online fallbacks --- +UV_BIN="" +ARCH=$(uname -m) +case "${ARCH}" in + x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;; + aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;; + *) BUNDLED_UV="" ;; +esac + +if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then + mkdir -p "${TRIM_PKGVAR}/bin" + cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv" + UV_BIN="${TRIM_PKGVAR}/bin/uv" +fi + +if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then + UV_BIN="uv" +fi + +if [ -z "${UV_BIN}" ]; then + "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ + "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \ + curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true + export PATH="${HOME}/.local/bin:${PATH}" + if command -v uv >/dev/null 2>&1; then + UV_BIN="uv" + elif [ -x "${HOME}/.local/bin/uv" ]; then + UV_BIN="${HOME}/.local/bin/uv" + fi +fi + +if [ -z "${UV_BIN}" ]; then + echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +# --- Create venv via uv --- +if [ ! -d ".venv" ]; then + "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { + echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } +fi + +# --- Sync dependencies --- +"${UV_BIN}" sync --extra seekdb || { + echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# --- Verify frontend dist --- +if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then + echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +exit 0 diff --git a/packaging/fnos/cmd/install_init b/packaging/fnos/cmd/install_init new file mode 100755 index 000000000..2e940ee03 --- /dev/null +++ b/packaging/fnos/cmd/install_init @@ -0,0 +1,5 @@ +#!/bin/bash +# cmd/install_init - pre-install hook +# Nothing special to do before extraction. + +exit 0 diff --git a/packaging/fnos/cmd/main b/packaging/fnos/cmd/main new file mode 100755 index 000000000..a3181c383 --- /dev/null +++ b/packaging/fnos/cmd/main @@ -0,0 +1,194 @@ +#!/bin/bash +# cmd/main - LangBot lifecycle manager for fnOS +# Handles start / stop / status via standalone-runtime Python process. +# Node.js path is injected into PATH so Box sandbox npx MCP servers can run. + +PID_FILE="${TRIM_PKGVAR}/langbot.pid" +APP_DIR="${TRIM_APPDEST}/langbot" +LOG_FILE="${TRIM_PKGVAR}/langbot.log" + +# --- Locate fnOS Node.js bin path --- +# fnOS appname-based path: /var/apps/nodejs_vXX/target/bin +# This is a stable symlink regardless of which volume the app is on. +NODE_VERSION="${wizard_node_version:-22}" +NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin" + +if [ -d "${NODE_BIN_DIR}" ]; then + export PATH="${NODE_BIN_DIR}:${PATH}" +fi + +# --- Persistent data root --- +# LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py: +# load_yaml_config('data/config.yaml', ...)) and resolves its data root to +# /data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for +# config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR. +DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" +if [ -z "${DATA_DIR}" ]; then + DATA_DIR="${TRIM_PKGVAR}/data" +fi +export LANGBOT_DATA_ROOT="${DATA_DIR}" +mkdir -p "${DATA_DIR}" 2>/dev/null || true + +# --- Locate Python --- +PYTHON_BIN="python3" +! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" + +# --- Locate uv --- +# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv +UV_BIN="${TRIM_PKGVAR}/bin/uv" +if [ ! -x "${UV_BIN}" ]; then + UV_BIN="uv" +fi +if ! command -v "${UV_BIN}" >/dev/null 2>&1; then + UV_BIN="${HOME}/.local/bin/uv" +fi +if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then + UV_BIN="${HOME}/.cargo/bin/uv" +fi +if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then + UV_BIN="${APP_DIR}/.venv/bin/uv" +fi + +case $1 in + start) + if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then + exit 0 + fi + rm -f "${PID_FILE}" + fi + + if [ ! -d "${APP_DIR}" ]; then + echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + fi + + cd "${APP_DIR}" || { + echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } + + if [ ! -d ".venv" ]; then + echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}" + exit 1 + fi + + # --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR --- + # LangBot reads config CWD-relative (data/config.yaml), so without this a + # fresh data/ with a default 5300 config gets recreated inside target/ on + # every install/upgrade. The symlink keeps everything on the persistent + # share; it is recreated here on each start (upgrades wipe target/). + APP_DATA="${APP_DIR}/data" + if [ -L "${APP_DATA}" ]; then + # already a symlink; re-point if the persistent dir changed + [ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}" + elif [ -d "${APP_DATA}" ]; then + # legacy real dir (created by LangBot before this fix): merge into the + # persistent dir without overwriting newer files already there + mkdir -p "${DATA_DIR}" + cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/" + rm -rf "${APP_DATA}" + ln -s "${DATA_DIR}" "${APP_DATA}" + else + ln -s "${DATA_DIR}" "${APP_DATA}" + fi + + # Ensure LangBot's actual listen port always matches what fnOS shows in + # "应用设置 → 访问端口" (which is the single source of truth from the user's + # POV). Two sources, checked in priority order: + # 1. ${wizard_port} — only set during install/upgrade callbacks (not on + # normal `start`; kept for completeness). + # 2. target/ui/config — read the "port" field written by fnOS after + # ${wizard_port} substitution AND any later edit the user made via + # "应用设置 → 自定义 URL" pencil button. + # Without this: user picks 5303 in wizard, LangBot still listens on its + # default 5300, desktop shortcut hits 5303 → connection refused. + CONFIG_FILE="${DATA_DIR}/config.yaml" + _patch_port() { + local _port="$1" + case "${_port}" in + ''|*[!0-9]*) return ;; + esac + if [ ! -f "${CONFIG_FILE}" ]; then + local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml" + [ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}" + fi + if [ -f "${CONFIG_FILE}" ]; then + sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}" + sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}" + fi + } + if [ -n "${wizard_port:-}" ]; then + _patch_port "${wizard_port}" + fi + if [ -f "${TRIM_APPDEST}/ui/config" ]; then + _port_from_ui=$(python3 -c 'import json,sys +try: + d = json.load(open(sys.argv[1])) + for _name, _entry in (d.get(".url") or {}).items(): + p = _entry.get("port") + if isinstance(p, (int, float)): + print(int(p)) + elif isinstance(p, str) and p.isdigit(): + print(int(p)) + break +except Exception: + pass +' "${TRIM_APPDEST}/ui/config" 2>/dev/null) + if [ -n "${_port_from_ui}" ]; then + _patch_port "${_port_from_ui}" + fi + fi + + # Native deployment: no --standalone-runtime flag, LangBot spawns the + # plugin runtime as a stdio subprocess (same as official `uv run main.py`). + # (--standalone-runtime would require an external runtime at + # ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.) + # --standalone-box omitted: Box sandbox defaults off, users enable via Web UI + nohup "${UV_BIN}" run --no-sync main.py \ + > "${LOG_FILE}" 2>&1 & + echo $! > "${PID_FILE}" + + sleep 3 + if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then + exit 0 + fi + fi + echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + ;; + + stop) + if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ]; then + kill "${PID}" 2>/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "${PID}" 2>/dev/null || break + sleep 1 + done + kill -9 "${PID}" 2>/dev/null + fi + rm -f "${PID_FILE}" + fi + exit 0 + ;; + + status) + if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then + exit 0 + fi + rm -f "${PID_FILE}" + fi + exit 3 + ;; + + *) + exit 1 + ;; +esac diff --git a/packaging/fnos/cmd/uninstall_callback b/packaging/fnos/cmd/uninstall_callback new file mode 100755 index 000000000..2baee90d9 --- /dev/null +++ b/packaging/fnos/cmd/uninstall_callback @@ -0,0 +1,21 @@ +#!/bin/bash +# cmd/uninstall_callback - post-uninstall hook +# The system preserves var/ and shares/ by default. Honor the user's +# wizard_keep_data choice: delete data only when explicitly requested. + +if [ "${wizard_keep_data:-yes}" = "no" ]; then + # 应用运行数据(pid、日志等) + if [ -n "${TRIM_PKGVAR}" ]; then + rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \ + "${TRIM_PKGVAR:?}"/langbot.log \ + "${TRIM_PKGVAR:?}"/bin 2>/dev/null || true + fi + + # 共享数据目录(langbot/data) + DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" + if [ -n "${DATA_DIR}" ]; then + rm -rf "${DATA_DIR:?}" 2>/dev/null || true + fi +fi + +exit 0 diff --git a/packaging/fnos/cmd/uninstall_init b/packaging/fnos/cmd/uninstall_init new file mode 100755 index 000000000..800ced14c --- /dev/null +++ b/packaging/fnos/cmd/uninstall_init @@ -0,0 +1,20 @@ +#!/bin/bash +# cmd/uninstall_init - pre-uninstall hook +# Stop LangBot before files are removed. + +PID_FILE="${TRIM_PKGVAR}/langbot.pid" + +if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then + kill "${PID}" 2>/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "${PID}" 2>/dev/null || break + sleep 1 + done + kill -9 "${PID}" 2>/dev/null + fi + rm -f "${PID_FILE}" +fi + +exit 0 diff --git a/packaging/fnos/cmd/upgrade_callback b/packaging/fnos/cmd/upgrade_callback new file mode 100755 index 000000000..9288c866c --- /dev/null +++ b/packaging/fnos/cmd/upgrade_callback @@ -0,0 +1,77 @@ +#!/bin/bash +# cmd/upgrade_callback - post-upgrade hook +# Re-sync dependencies after code replacement using uv. + +APP_DIR="${TRIM_APPDEST}/langbot" + +# Persistent data root (must match cmd/main) +DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" +[ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data" + +# Apply port from upgrade wizard (config persists across upgrades; this +# only rewrites it when the user changed the value in the upgrade wizard) +CONFIG_FILE="${DATA_DIR}/config.yaml" +if [ -n "${wizard_port:-}" ] && [ -f "${CONFIG_FILE}" ]; then + case "${wizard_port}" in + ''|*[!0-9]*) ;; + *) + sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${wizard_port}/" "${CONFIG_FILE}" + sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${wizard_port}'#" "${CONFIG_FILE}" + ;; + esac +fi + +cd "${APP_DIR}" || { + echo "App directory missing after upgrade" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin) +UV_BIN="${TRIM_PKGVAR}/bin/uv" +if [ ! -x "${UV_BIN}" ]; then + UV_BIN="uv" +fi +if ! command -v "${UV_BIN}" >/dev/null 2>&1; then + UV_BIN="${HOME}/.local/bin/uv" +fi +if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then + UV_BIN="${HOME}/.cargo/bin/uv" +fi + +PYTHON_BIN="python3" +! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" + +# Re-sync deps +if [ -d ".venv" ]; then + "${UV_BIN}" sync --extra seekdb 2>/dev/null || { + echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } +else + # Venv was lost, recreate via uv + if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then + "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ + "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || { + echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } + export PATH="${HOME}/.local/bin:${PATH}" + UV_BIN="uv" + fi + "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { + echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } + "${UV_BIN}" sync --extra seekdb || { + echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } +fi + +# Verify frontend dist still present +if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then + echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" + exit 1 +fi + +exit 0 diff --git a/packaging/fnos/cmd/upgrade_init b/packaging/fnos/cmd/upgrade_init new file mode 100755 index 000000000..fdac71831 --- /dev/null +++ b/packaging/fnos/cmd/upgrade_init @@ -0,0 +1,20 @@ +#!/bin/bash +# cmd/upgrade_init - pre-upgrade hook +# Stop the running LangBot process before files are replaced. + +PID_FILE="${TRIM_PKGVAR}/langbot.pid" + +if [ -f "${PID_FILE}" ]; then + PID=$(cat "${PID_FILE}" | tr -d '[:space:]') + if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then + kill "${PID}" 2>/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "${PID}" 2>/dev/null || break + sleep 1 + done + kill -9 "${PID}" 2>/dev/null + fi + rm -f "${PID_FILE}" +fi + +exit 0 diff --git a/packaging/fnos/config/privilege b/packaging/fnos/config/privilege new file mode 100644 index 000000000..e21db569b --- /dev/null +++ b/packaging/fnos/config/privilege @@ -0,0 +1,5 @@ +{ + "defaults": { + "run-as": "root" + } +} diff --git a/packaging/fnos/config/resource b/packaging/fnos/config/resource new file mode 100644 index 000000000..eef99d345 --- /dev/null +++ b/packaging/fnos/config/resource @@ -0,0 +1,9 @@ +{ + "data-share": { + "shares": [ + { + "name": "langbot/data" + } + ] + } +} diff --git a/packaging/fnos/manifest b/packaging/fnos/manifest new file mode 100644 index 000000000..98699b795 --- /dev/null +++ b/packaging/fnos/manifest @@ -0,0 +1,16 @@ +appname=langbot +version=4.10.10 +display_name=LangBot +desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。 +platform=all +source=thirdparty +maintainer=LangBot +maintainer_url=https://langbot.app +service_port=5300 +checkport=true +os_min_version=0.9.0 +desktop_uidir=ui +desktop_applaunchname=langbot.main +ctl_stop=true +install_dep_apps=nodejs_v22 +changelog=飞牛 fnOS 增强版首版:原生 Python 部署,依赖 Node.js v22 以启用 Box 沙箱 + npx MCP 能力 diff --git a/packaging/fnos/wizard/install b/packaging/fnos/wizard/install new file mode 100644 index 000000000..d9833facf --- /dev/null +++ b/packaging/fnos/wizard/install @@ -0,0 +1,47 @@ +[ + { + "stepTitle": "运行环境", + "items": [ + { + "type": "tips", + "helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。请先在应用中心安装 Node.js v22。" + }, + { + "type": "select", + "field": "wizard_node_version", + "label": "Node.js 版本", + "initValue": "22", + "options": [ + { "label": "Node.js v22 (推荐, LTS)", "value": "22" }, + { "label": "Node.js v24", "value": "24" }, + { "label": "Node.js v20", "value": "20" } + ] + } + ] + }, + { + "stepTitle": "访问配置", + "items": [ + { + "type": "text", + "field": "wizard_port", + "label": "Web 访问端口", + "initValue": "5300", + "rules": [ + { "required": true, "message": "请输入访问端口" }, + { "pattern": "^[0-9]+$", "message": "端口只能是数字" }, + { "min": 1, "max": 5, "message": "端口号长度不正确" } + ] + } + ] + }, + { + "stepTitle": "安装说明", + "items": [ + { + "type": "tips", + "helpText": "安装完成后,LangBot 首次启动约需 5-10 分钟完成依赖部署与初始化,部署完成后即可打开网页端使用。" + } + ] + } +] diff --git a/packaging/fnos/wizard/uninstall b/packaging/fnos/wizard/uninstall new file mode 100644 index 000000000..5b96987fb --- /dev/null +++ b/packaging/fnos/wizard/uninstall @@ -0,0 +1,17 @@ +[ + { + "stepTitle": "数据保留", + "items": [ + { + "type": "radio", + "field": "wizard_keep_data", + "label": "是否保留 LangBot 数据(插件、配置、日志)", + "initValue": "yes", + "options": [ + { "label": "保留数据(重新安装后可继续使用)", "value": "yes" }, + { "label": "彻底删除全部数据", "value": "no" } + ] + } + ] + } +] diff --git a/packaging/fnos/wizard/upgrade b/packaging/fnos/wizard/upgrade new file mode 100644 index 000000000..718264df7 --- /dev/null +++ b/packaging/fnos/wizard/upgrade @@ -0,0 +1,38 @@ +[ + { + "stepTitle": "运行环境", + "items": [ + { + "type": "tips", + "helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。如需更换版本,请先在应用中心安装对应版本。" + }, + { + "type": "select", + "field": "wizard_node_version", + "label": "Node.js 版本", + "initValue": "22", + "options": [ + { "label": "Node.js v22 (推荐, LTS)", "value": "22" }, + { "label": "Node.js v24", "value": "24" }, + { "label": "Node.js v20", "value": "20" } + ] + } + ] + }, + { + "stepTitle": "访问配置", + "items": [ + { + "type": "text", + "field": "wizard_port", + "label": "Web 访问端口", + "initValue": "5300", + "rules": [ + { "required": true, "message": "请输入访问端口" }, + { "pattern": "^[0-9]+$", "message": "端口只能是数字" }, + { "min": 1, "max": 5, "message": "端口号长度不正确" } + ] + } + ] + } +] From eb4563775dd37308376315b05c49090189c39dea Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 15 Sep 2026 01:11:42 +0800 Subject: [PATCH 51/56] docs(readme): remove retired public demo from all languages (#2543) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- README.md | 11 ----------- README_CN.md | 10 ---------- README_ES.md | 10 ---------- README_FR.md | 10 ---------- README_JP.md | 10 ---------- README_KO.md | 10 ---------- README_RU.md | 10 ---------- README_TW.md | 10 ---------- README_VI.md | 10 ---------- 9 files changed, 91 deletions(-) diff --git a/README.md b/README.md index e0f2cae99..5de92d3ad 100644 --- a/README.md +++ b/README.md @@ -93,17 +93,6 @@ docker compose --profile all up -d --- -## Live Demo - -**Try it now:** https://demo.langbot.dev/ - -- Email: `demo@langbot.app` -- Password: `langbot123456` - -_Note: Public demo environment. Do not enter sensitive information._ - ---- - ## Supported Platforms | Platform | Status | Notes | diff --git a/README_CN.md b/README_CN.md index 49888f812..aad5c6efa 100644 --- a/README_CN.md +++ b/README_CN.md @@ -93,16 +93,6 @@ docker compose --profile all up -d --- -## 在线演示 - -**立即体验:** https://demo.langbot.dev/ -- 邮箱:`demo@langbot.app` -- 密码:`langbot123456` - -*注意:公开演示环境,请不要在其中填入任何敏感信息。* - ---- - ## 支持的平台 | 平台 | 状态 | 备注 | diff --git a/README_ES.md b/README_ES.md index 502047b9c..04ebc78a3 100644 --- a/README_ES.md +++ b/README_ES.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## Demo en Vivo - -**Pruébelo ahora:** https://demo.langbot.dev/ -- Correo electrónico: `demo@langbot.app` -- Contraseña: `langbot123456` - -*Nota: Entorno de demostración público. No ingrese información confidencial.* - ---- - ## Plataformas Soportadas | Plataforma | Estado | Notas | diff --git a/README_FR.md b/README_FR.md index 24aed3374..78d99c692 100644 --- a/README_FR.md +++ b/README_FR.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## Démo en Ligne - -**Essayez maintenant :** https://demo.langbot.dev/ -- Email : `demo@langbot.app` -- Mot de passe : `langbot123456` - -*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* - ---- - ## Plateformes Supportées | Plateforme | Statut | Notes | diff --git a/README_JP.md b/README_JP.md index c8569de23..b876bd4ee 100644 --- a/README_JP.md +++ b/README_JP.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## ライブデモ - -**今すぐ試す:** https://demo.langbot.dev/ -- メール: `demo@langbot.app` -- パスワード: `langbot123456` - -*注意: 公開デモ環境です。機密情報を入力しないでください。* - ---- - ## 対応プラットフォーム | プラットフォーム | ステータス | 備考 | diff --git a/README_KO.md b/README_KO.md index 78e8fea55..b28d3ec2c 100644 --- a/README_KO.md +++ b/README_KO.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## 라이브 데모 - -**지금 체험:** https://demo.langbot.dev/ -- 이메일: `demo@langbot.app` -- 비밀번호: `langbot123456` - -*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* - ---- - ## 지원 플랫폼 | 플랫폼 | 상태 | 비고 | diff --git a/README_RU.md b/README_RU.md index 33d03cfdf..f6c1f8bce 100644 --- a/README_RU.md +++ b/README_RU.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## Демо - -**Попробуйте прямо сейчас:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Пароль: `langbot123456` - -*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* - ---- - ## Поддерживаемые платформы | Платформа | Статус | Примечания | diff --git a/README_TW.md b/README_TW.md index 4a046d149..140515650 100644 --- a/README_TW.md +++ b/README_TW.md @@ -94,16 +94,6 @@ docker compose --profile all up -d --- -## 線上演示 - -**立即體驗:** https://demo.langbot.dev/ -- 信箱:`demo@langbot.app` -- 密碼:`langbot123456` - -*注意:公開演示環境,請不要在其中填入任何敏感資訊。* - ---- - ## 支援的平台 | 平台 | 狀態 | 備註 | diff --git a/README_VI.md b/README_VI.md index 50c64c280..356f577b3 100644 --- a/README_VI.md +++ b/README_VI.md @@ -92,16 +92,6 @@ docker compose --profile all up -d --- -## Demo trực tuyến - -**Thử ngay:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Mật khẩu: `langbot123456` - -*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* - ---- - ## Nền tảng được hỗ trợ | Nền tảng | Trạng thái | Ghi chú | From 38ff4766efedadb177d0ef34c0ec7f5d229e5971 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Tue, 15 Sep 2026 08:22:11 +0800 Subject: [PATCH 52/56] chore: update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 459a22c82..83b4faf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ packaging/fnos/ICON_256.PNG packaging/fnos/app/ui/images/ packaging/fnos/app/desktop/images/ packaging/fnos/*.fpk + +r.ps1 From 1143d6a5ae1893f5eb29b9d52b6b4d69866300a8 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Tue, 15 Sep 2026 17:58:23 +0800 Subject: [PATCH 53/56] feat(storage): media content-addressable cache and monitoring base64 externalization - Add MediaCache using xxHash3-128 (with sha256 fallback) content-addressable storage - Externalize message chain image payloads before recording monitoring and discarded messages - Strip base64 payloads to null in SQLite monitoring_messages, dropping row size from megabytes to hundreds of bytes - Add GET /api/v1/files/media/ route with immutable HTTP cache headers to serve cached media - Integrate age-based retention (default 30 days) and configurable disk quota with MaintenanceService cleanup loop - Add defensive sanitizer in MonitoringService.record_message against oversized raw base64 payloads - Add comprehensive unit tests and end-to-end verification covering CAS deduplication, route serving, and LRU pruning --- pyproject.toml | 1 + .../pkg/api/http/controller/groups/files.py | 15 ++ .../pkg/api/http/service/maintenance.py | 31 +++ .../pkg/api/http/service/monitoring.py | 28 +++ src/langbot/pkg/pipeline/monitoring_helper.py | 10 +- src/langbot/pkg/platform/botmgr.py | 5 +- src/langbot/pkg/storage/media.py | 235 ++++++++++++++++++ src/langbot/pkg/storage/mgr.py | 3 + src/langbot/templates/config.yaml | 6 + tests/unit_tests/storage/test_media_cache.py | 215 ++++++++++++++++ uv.lock | 120 ++++----- 11 files changed, 607 insertions(+), 62 deletions(-) create mode 100644 src/langbot/pkg/storage/media.py create mode 100644 tests/unit_tests/storage/test_media_cache.py diff --git a/pyproject.toml b/pyproject.toml index 1dc5e3467..b8880a6b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ "python-docx>=1.1.0", "pandas>=2.2.2", "chardet>=5.2.0", + "xxhash>=3.5.0", "markdown>=3.6", "beautifulsoup4>=4.12.3", "ebooklib>=0.18", diff --git a/src/langbot/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py index 026f6c194..e38c181ae 100644 --- a/src/langbot/pkg/api/http/controller/groups/files.py +++ b/src/langbot/pkg/api/http/controller/groups/files.py @@ -47,6 +47,21 @@ class FilesRouterGroup(group.RouterGroup): return quart.Response(image_bytes, mimetype=mime_type) + @self.route( + '/media/', + methods=['GET'], + auth_type=group.AuthType.NONE, + ) + async def get_media_file(filename: str) -> quart.Response: + media = await self.ap.storage_mgr.media_cache.get_media(filename) + if media is None: + return quart.Response('Media not found or expired', status=404) + media_bytes, mime_type = media + headers = { + 'Cache-Control': 'public, max-age=2592000, immutable', + } + return quart.Response(media_bytes, mimetype=mime_type, headers=headers) + @self.route( '/images', methods=['POST'], diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index 0c61618c6..562c17d61 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -80,6 +80,25 @@ class MaintenanceService: DEFAULT_LOG_RETENTION_DAYS, 'storage.cleanup.log_retention_days', ) + media_cfg = self.ap.instance_config.data.get('storage', {}).get('media_cache', {}) + media_retention_days = self._positive_int( + media_cfg.get('retention_days'), + 30, + 'storage.media_cache.retention_days', + ) + media_max_size_mb = self._non_negative_int( + media_cfg.get('max_size_mb'), + 0, + 'storage.media_cache.max_size_mb', + ) + media_cleanup = ( + await self.ap.storage_mgr.media_cache.cleanup( + media_retention_days, + media_max_size_mb, + ) + if hasattr(self.ap.storage_mgr, 'media_cache') and await self._is_oss_singleton(context) + else {} + ) return { 'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days), @@ -89,6 +108,7 @@ class MaintenanceService: ) if await self._is_oss_singleton(context) else 0, + 'media_files': media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0), } async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]: @@ -466,6 +486,17 @@ class MaintenanceService: count += len(files) return count + def _non_negative_int(self, value: Any, default: int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed < 0: + self.ap.logger.warning(f'{name} must be non-negative: {value!r}, using {default}') + return default + return parsed + def _positive_int(self, value: Any, default: int, name: str) -> int: try: parsed = int(value) diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index c90cec6d1..0bae983a6 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import uuid import datetime import functools @@ -417,6 +418,32 @@ class MonitoringService: # ========== Recording Methods ========== + def _sanitize_message_content(self, content: str) -> str: + """Strip raw base64 data to protect database storage from unbounded bloating.""" + if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content): + return content + try: + data = json.loads(content) + + def _strip_node(node): + if isinstance(node, list): + return [_strip_node(x) for x in node] + if isinstance(node, dict): + res = dict(node) + if res.get('type') == 'Image' and res.get('base64'): + res['base64'] = None + for k, v in list(res.items()): + if isinstance(v, (list, dict)): + res[k] = _strip_node(v) + return res + return node + + return json.dumps(_strip_node(data), ensure_ascii=False) + except Exception: + return re.sub( + r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content + ) + @_workspace_transaction async def record_message( self, @@ -439,6 +466,7 @@ class MonitoringService: """Record a message""" workspace_uuid = self._require_write_context(context) message_id = str(uuid.uuid4()) + message_content = self._sanitize_message_content(message_content) message_data = { 'id': message_id, 'workspace_uuid': workspace_uuid, diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index 1bab4bda4..15ae38f47 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -48,7 +48,10 @@ class MonitoringHelper: # Try to record message # Use JSON serialization to preserve message chain structure (including image URLs, etc.) if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'): - message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False) + chain_dump = query.message_chain.model_dump() + if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'): + chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) + message_content = json.dumps(chain_dump, ensure_ascii=False) else: message_content = str(query) @@ -168,7 +171,10 @@ class MonitoringHelper: if hasattr(last_resp, 'get_content_platform_message_chain'): chain = last_resp.get_content_platform_message_chain() if hasattr(chain, 'model_dump'): - message_content = json.dumps(chain.model_dump(), ensure_ascii=False) + chain_dump = chain.model_dump() + if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'): + chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) + message_content = json.dumps(chain_dump, ensure_ascii=False) else: message_content = str(chain) else: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index feaeea7a9..76a9ff03d 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -210,7 +210,10 @@ class RuntimeBot: """Record a discarded message in the monitoring system.""" try: if hasattr(message_chain, 'model_dump'): - message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False) + chain_dump = message_chain.model_dump() + if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'): + chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) + message_content = json.dumps(chain_dump, ensure_ascii=False) else: message_content = str(message_chain) diff --git a/src/langbot/pkg/storage/media.py b/src/langbot/pkg/storage/media.py new file mode 100644 index 000000000..74d882da1 --- /dev/null +++ b/src/langbot/pkg/storage/media.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import asyncio +import base64 +import copy +import datetime +import hashlib +import mimetypes +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +try: + import xxhash +except ImportError: + xxhash = None + +if TYPE_CHECKING: + from ...core import app + from . import mgr as storage_mgr + +DEFAULT_RETENTION_DAYS = 30 +DEFAULT_MAX_SIZE_MB = 0 +MEDIA_DIR = 'media_cache' +SAFE_MEDIA_FILENAME = re.compile(r'^[a-f0-9]{32,64}(\.[a-zA-Z0-9]{1,10})?$') + + +class MediaCache: + """Content-addressable storage cache for images and media attachments. + + Deduplicates media files using xxHash3-128 (with sha256 fallback), + offloads payloads from SQLite to StorageProvider, and implements LRU + and age-based retention cleanup. + """ + + def __init__(self, ap: app.Application, storage_mgr: storage_mgr.StorageMgr): + self.ap = ap + self.storage_mgr = storage_mgr + + @staticmethod + def hash_bytes(data: bytes) -> str: + """Compute content-addressable hash for binary data.""" + if xxhash is not None: + return xxhash.xxh3_128_hexdigest(data) + return hashlib.sha256(data).hexdigest()[:32] + + @staticmethod + def parse_data_url(data_url: str) -> tuple[bytes, str] | None: + """Parse a data URL or raw base64 string into bytes and mime type.""" + if not data_url or not isinstance(data_url, str): + return None + try: + if data_url.startswith('data:'): + split_index = data_url.find(';base64,') + if split_index != -1: + mime_type = data_url[5:split_index] + b64_data = data_url[split_index + 8 :] + return base64.b64decode(b64_data), mime_type + # Try raw base64 if sufficiently long + if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')): + return base64.b64decode(data_url), 'application/octet-stream' + except Exception: + return None + return None + + @staticmethod + def guess_extension(mime_type: str | None, default: str = '.jpg') -> str: + """Guess appropriate file extension from MIME type.""" + if not mime_type: + return default + mime_lower = mime_type.lower() + if 'png' in mime_lower: + return '.png' + if 'webp' in mime_lower: + return '.webp' + if 'gif' in mime_lower: + return '.gif' + if 'jpeg' in mime_lower or 'jpg' in mime_lower: + return '.jpg' + ext = mimetypes.guess_extension(mime_type) + if ext == '.jpe': + return '.jpg' + return ext or default + + async def save_media(self, data: bytes, mime_type: str | None = None) -> tuple[str, str, int]: + """Save media bytes into content-addressable storage cache. + + Returns: + Tuple of (hash_str, storage_key, byte_size) + """ + hash_str = self.hash_bytes(data) + ext = self.guess_extension(mime_type) + storage_key = f'{MEDIA_DIR}/{hash_str}{ext}' + provider = self.storage_mgr.storage_provider + + if not await provider.exists(storage_key): + await provider.save(storage_key, data) + else: + await self.touch(storage_key) + + return hash_str, storage_key, len(data) + + async def get_media(self, filename_or_key: str) -> tuple[bytes, str] | None: + """Retrieve media bytes and mime type by key or filename.""" + filename = os.path.basename(filename_or_key) + if not SAFE_MEDIA_FILENAME.match(filename): + return None + storage_key = f'{MEDIA_DIR}/{filename}' + provider = self.storage_mgr.storage_provider + + if not await provider.exists(storage_key): + return None + + data = await self.storage_mgr._load_object_bounded(storage_key) + mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + await self.touch(storage_key) + return data, mime_type + + async def touch(self, storage_key: str) -> None: + """Update access/modified time of a media file for LRU tracking.""" + provider = getattr(self.storage_mgr, 'storage_provider', None) + if provider is not None and provider.__class__.__name__ == 'LocalStorageProvider': + full_path = os.path.join('data', 'storage', storage_key) + if os.path.exists(full_path): + now = datetime.datetime.now().timestamp() + try: + await asyncio.to_thread(os.utime, full_path, (now, now)) + except Exception: + pass + + async def cleanup( + self, + retention_days: int = DEFAULT_RETENTION_DAYS, + max_size_mb: int = DEFAULT_MAX_SIZE_MB, + ) -> dict[str, int]: + """Perform age-based and LRU size-based cleanup on media cache. + + Args: + retention_days: Retain media accessed within this many days (default 30). + max_size_mb: Maximum total size in MB (0 means unlimited). + + Returns: + Dictionary of cleanup metrics. + """ + provider = getattr(self.storage_mgr, 'storage_provider', None) + if provider is None or provider.__class__.__name__ != 'LocalStorageProvider': + return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0} + + target_dir = Path('data/storage') / MEDIA_DIR + if not target_dir.exists() or not target_dir.is_dir(): + return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0} + + now = datetime.datetime.now().timestamp() + cutoff = (now - retention_days * 86400) if retention_days > 0 else 0 + + expired_deleted = 0 + size_deleted = 0 + bytes_freed = 0 + remaining: list[tuple[Path, int, float]] = [] + + for entry in target_dir.iterdir(): + if not entry.is_file(): + continue + try: + stat = entry.stat() + except OSError: + continue + + if cutoff > 0 and stat.st_mtime < cutoff: + try: + entry.unlink(missing_ok=True) + expired_deleted += 1 + bytes_freed += stat.st_size + except OSError: + pass + else: + remaining.append((entry, stat.st_size, stat.st_mtime)) + + if max_size_mb > 0: + max_bytes = max_size_mb * 1024 * 1024 + total_bytes = sum(item[1] for item in remaining) + if total_bytes > max_bytes: + remaining.sort(key=lambda item: item[2]) + for path, size, _ in remaining: + if total_bytes <= max_bytes: + break + try: + path.unlink(missing_ok=True) + size_deleted += 1 + bytes_freed += size + total_bytes -= size + except OSError: + pass + + return { + 'expired_deleted': expired_deleted, + 'size_deleted': size_deleted, + 'bytes_freed': bytes_freed, + } + + async def externalize_chain_dump(self, chain_dump: Any) -> Any: + """Recursively extract raw base64 media into cache and replace with references.""" + if isinstance(chain_dump, list): + return [await self.externalize_chain_dump(item) for item in chain_dump] + if isinstance(chain_dump, dict): + node = copy.copy(chain_dump) + node_type = node.get('type') + if node_type == 'Image': + b64 = node.get('base64') + if b64 and isinstance(b64, str): + try: + parsed = self.parse_data_url(b64) + if parsed is not None: + raw_bytes, mime_type = parsed + hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type) + filename = os.path.basename(storage_key) + current_url = node.get('url') or '' + if current_url and not current_url.startswith('data:'): + node['original_url'] = current_url + node['url'] = f'/api/v1/files/media/{filename}' + node['base64'] = None + node['hash'] = hash_str + node['storage_key'] = storage_key + node['size'] = size + node['mime_type'] = mime_type + except Exception as e: + if hasattr(self.ap, 'logger') and self.ap.logger: + self.ap.logger.warning(f'Failed to externalize image to media cache: {e}') + node['base64'] = None + for k, v in list(node.items()): + if isinstance(v, (list, dict)): + node[k] = await self.externalize_chain_dump(v) + return node + return chain_dump diff --git a/src/langbot/pkg/storage/mgr.py b/src/langbot/pkg/storage/mgr.py index c6c7c8a93..5a96eaa7d 100644 --- a/src/langbot/pkg/storage/mgr.py +++ b/src/langbot/pkg/storage/mgr.py @@ -34,6 +34,9 @@ class StorageMgr: def __init__(self, ap: app.Application): self.ap = ap + from . import media + + self.media_cache = media.MediaCache(ap, self) def _object_read_limit(self) -> int: config = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 6b3c716fa..24efaba09 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -227,6 +227,12 @@ storage: # Bound every object materialized into Core memory. Built-in Local/S3 # providers enforce this while reading (hard cap: 64 MiB). max_object_read_bytes: 10485760 + # Media content cache (images & attachments externalized from monitoring and pipelines) + media_cache: + # Retention period in days for cached media (defaults to 30 days) + retention_days: 30 + # Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0) + max_size_mb: 0 cleanup: # Enable periodic cleanup of local/S3 uploaded files and old log files enabled: true diff --git a/tests/unit_tests/storage/test_media_cache.py b/tests/unit_tests/storage/test_media_cache.py new file mode 100644 index 000000000..054ee725b --- /dev/null +++ b/tests/unit_tests/storage/test_media_cache.py @@ -0,0 +1,215 @@ +import base64 +import datetime +import os +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch +import pytest +from quart import Quart + +from langbot.pkg.storage.media import MediaCache, SAFE_MEDIA_FILENAME +from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup + + +class TestMediaCache: + def setup_method(self): + self.mock_app = Mock() + self.mock_app.logger = Mock() + self.mock_storage_mgr = Mock() + self.mock_provider = Mock() + self.mock_provider.__class__.__name__ = 'LocalStorageProvider' + self.mock_provider.exists = AsyncMock(return_value=False) + self.mock_provider.save = AsyncMock() + self.mock_provider.load = AsyncMock() + self.mock_storage_mgr.storage_provider = self.mock_provider + self.mock_storage_mgr._load_object_bounded = AsyncMock() + self.media_cache = MediaCache(self.mock_app, self.mock_storage_mgr) + + def test_safe_media_filename_regex(self): + assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png') + assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.jpg') + assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c') + assert not SAFE_MEDIA_FILENAME.match('../etc/passwd') + assert not SAFE_MEDIA_FILENAME.match('foo/bar.png') + assert not SAFE_MEDIA_FILENAME.match('test.exe') + assert not SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3cXpng') + + def test_hash_bytes(self): + data1 = b'hello image content' + data2 = b'hello image content' + data3 = b'different content' + assert self.media_cache.hash_bytes(data1) == self.media_cache.hash_bytes(data2) + assert self.media_cache.hash_bytes(data1) != self.media_cache.hash_bytes(data3) + + def test_parse_data_url(self): + raw = b'png binary data here' + b64_str = base64.b64encode(raw).decode('ascii') + data_url = f'data:image/png;base64,{b64_str}' + + parsed = self.media_cache.parse_data_url(data_url) + assert parsed is not None + data, mime = parsed + assert data == raw + assert mime == 'image/png' + + @pytest.mark.asyncio + async def test_save_media_deduplication(self): + raw = b'fake png bytes' + hash_str = self.media_cache.hash_bytes(raw) + + # First save: provider.exists is False -> calls provider.save + h1, key1, size1 = await self.media_cache.save_media(raw, 'image/png') + assert h1 == hash_str + assert key1 == f'media_cache/{hash_str}.png' + assert size1 == len(raw) + self.mock_provider.save.assert_called_once_with(key1, raw) + + # Second save: provider.exists is True -> does not call provider.save again + self.mock_provider.exists.return_value = True + self.mock_provider.save.reset_mock() + h2, key2, size2 = await self.media_cache.save_media(raw, 'image/png') + assert h2 == h1 + assert key2 == key1 + self.mock_provider.save.assert_not_called() + + @pytest.mark.asyncio + async def test_get_media(self): + raw = b'stored bytes' + self.mock_provider.exists.return_value = True + self.mock_storage_mgr._load_object_bounded.return_value = raw + + valid_name = '3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png' + res = await self.media_cache.get_media(valid_name) + assert res is not None + data, mime = res + assert data == raw + assert mime == 'image/png' + + # Rejects invalid names + assert await self.media_cache.get_media('../malicious.png') is None + + @pytest.mark.asyncio + async def test_externalize_chain_dump(self): + raw = b'tiny image' + b64 = f'data:image/png;base64,{base64.b64encode(raw).decode("ascii")}' + chain_dump = [ + {'type': 'Plain', 'text': 'hello'}, + {'type': 'Image', 'url': 'https://multimedia.nt.qq.com.cn/download?appid=1407', 'base64': b64}, + {'type': 'Quote', 'origin': [{'type': 'Image', 'url': '', 'base64': b64}]}, + ] + + result = await self.media_cache.externalize_chain_dump(chain_dump) + + # Root image + img = result[1] + assert img['base64'] is None + assert img['hash'] == self.media_cache.hash_bytes(raw) + assert img['storage_key'].startswith('media_cache/') + assert img['original_url'] == 'https://multimedia.nt.qq.com.cn/download?appid=1407' + assert img['url'] == f'/api/v1/files/media/{img["hash"]}.png' + assert img['size'] == len(raw) + + # Nested quote image + nested_img = result[2]['origin'][0] + assert nested_img['base64'] is None + assert nested_img['hash'] == self.media_cache.hash_bytes(raw) + assert nested_img['url'] == f'/api/v1/files/media/{nested_img["hash"]}.png' + + @pytest.mark.asyncio + async def test_externalize_chain_dump_error_resilience(self): + raw = b'broken image' + b64 = f'data:image/png;base64,{base64.b64encode(raw).decode("ascii")}' + chain_dump = [{'type': 'Image', 'base64': b64}] + + with patch.object(self.media_cache, 'save_media', side_effect=OSError('Disk full')): + result = await self.media_cache.externalize_chain_dump(chain_dump) + # Should not raise; base64 should be stripped as fallback + assert result[0]['base64'] is None + + @pytest.mark.asyncio + async def test_cleanup_retention_and_max_size(self): + with tempfile.TemporaryDirectory() as temp_dir: + base_path = Path(temp_dir) + cache_dir = base_path / 'data' / 'storage' / 'media_cache' + cache_dir.mkdir(parents=True) + + # Create 3 test files with different mtimes and sizes + f1 = cache_dir / 'old_expired.png' + f1.write_bytes(b'x' * 1000) + old_time = (datetime.datetime.now() - datetime.timedelta(days=35)).timestamp() + os.utime(f1, (old_time, old_time)) + + f2 = cache_dir / 'recent_large1.png' + f2.write_bytes(b'x' * 500) + t2 = (datetime.datetime.now() - datetime.timedelta(days=5)).timestamp() + os.utime(f2, (t2, t2)) + + f3 = cache_dir / 'recent_large2.png' + f3.write_bytes(b'x' * 500) + t3 = (datetime.datetime.now() - datetime.timedelta(days=1)).timestamp() + os.utime(f3, (t3, t3)) + + with patch('langbot.pkg.storage.media.Path') as mock_path: + mock_path.return_value = base_path / 'data' / 'storage' + # Run cleanup with 30-day retention and max_size_mb = 0 (unlimited) + stats = await self.media_cache.cleanup(retention_days=30, max_size_mb=0) + assert stats['expired_deleted'] == 1 + assert not f1.exists() + assert f2.exists() + assert f3.exists() + + # Run cleanup with max_size_mb limited to ~0.0006 MB (< 1000 bytes) + # Total is currently 1000 bytes (f2=500 + f3=500). Max size 600 bytes -> oldest f2 must be purged + stats2 = await self.media_cache.cleanup(retention_days=30, max_size_mb=0.0006) + assert stats2['size_deleted'] >= 1 + assert not f2.exists() + assert f3.exists() + + @pytest.mark.asyncio + async def test_files_media_endpoint(self): + quart_app = Quart(__name__) + mock_app = Mock() + mock_app.storage_mgr = self.mock_storage_mgr + + router = FilesRouterGroup(mock_app, quart_app) + await router.initialize() + + client = quart_app.test_client() + + # 1. 404 on not found + mock_cache = Mock() + mock_cache.get_media = AsyncMock(return_value=None) + self.mock_storage_mgr.media_cache = mock_cache + resp_404 = await client.get('/api/v1/files/media/3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png') + assert resp_404.status_code == 404 + + # 2. 200 on found with cache headers + mock_cache.get_media.return_value = (b'fake image data', 'image/png') + resp_200 = await client.get('/api/v1/files/media/3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png') + assert resp_200.status_code == 200 + assert await resp_200.get_data() == b'fake image data' + assert 'public' in resp_200.headers.get('Cache-Control', '') + assert 'image/png' in resp_200.headers.get('Content-Type', '') + + +class TestMonitoringServiceSanitization: + def test_sanitize_oversized_base64_payload(self): + svc = MonitoringService.__new__(MonitoringService) + small_content = '{"type": "Image", "base64": "data:image/png;base64,tiny"}' + # Should leave small contents untouched + assert svc._sanitize_message_content(small_content) == small_content + + # Large content with base64 data URL + huge_b64 = 'A' * 60000 + large_content = f'{{"type": "Image", "base64": "data:image/png;base64,{huge_b64}"}}' + sanitized = svc._sanitize_message_content(large_content) + assert huge_b64 not in sanitized + assert '"base64": null' in sanitized or '"base64":null' in sanitized + + # Non-JSON content with multi-line base64 + multiline_b64 = ('A' * 70 + '\r\n') * 300 + raw_corrupted = 'prefix data:image/png;base64,' + multiline_b64 + ' suffix' + sanitized_raw = svc._sanitize_message_content(raw_corrupted) + assert '[base64 image omitted]' in sanitized_raw + assert multiline_b64 not in sanitized_raw diff --git a/uv.lock b/uv.lock index 8d718eb73..107889d6b 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1099,34 +1099,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -2135,6 +2135,7 @@ dependencies = [ { name = "valkey-glide", marker = "sys_platform != 'win32'" }, { name = "webauthn" }, { name = "websockets" }, + { name = "xxhash" }, ] [package.optional-dependencies] @@ -2233,6 +2234,7 @@ requires-dist = [ { name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" }, { name = "webauthn", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, + { name = "xxhash", specifier = ">=3.5.0" }, ] provides-extras = ["seekdb"] @@ -3299,7 +3301,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3338,7 +3340,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3350,7 +3352,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3380,9 +3382,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3394,7 +3396,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4487,7 +4489,7 @@ name = "pylibseekdb" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pymysql" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" }, @@ -5255,10 +5257,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5305,7 +5307,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5376,14 +5378,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "torch", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "transformers", marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5756,21 +5758,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "filelock", marker = "python_full_version >= '3.14'" }, - { name = "fsspec", marker = "python_full_version >= '3.14'" }, - { name = "jinja2", marker = "python_full_version >= '3.14'" }, - { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.14'" }, - { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5812,15 +5814,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pyyaml", marker = "python_full_version >= '3.14'" }, - { name = "regex", marker = "python_full_version >= '3.14'" }, - { name = "safetensors", marker = "python_full_version >= '3.14'" }, - { name = "tokenizers", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "typer", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -6081,9 +6083,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'win32'" }, - { name = "protobuf", marker = "sys_platform != 'win32'" }, - { name = "sniffio", marker = "sys_platform != 'win32'" }, + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From a32b3121d3e1525470b40cb62a53401f21777485 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Tue, 15 Sep 2026 18:14:29 +0800 Subject: [PATCH 54/56] build(boot): register xxhash in startup dependency check --- src/langbot/pkg/core/bootutils/deps.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/langbot/pkg/core/bootutils/deps.py b/src/langbot/pkg/core/bootutils/deps.py index 2cfd57e0c..b13d138b6 100644 --- a/src/langbot/pkg/core/bootutils/deps.py +++ b/src/langbot/pkg/core/bootutils/deps.py @@ -43,6 +43,7 @@ required_deps = { 'slack_sdk': 'slack_sdk', 'asyncpg': 'asyncpg', 'litellm': 'litellm', + 'xxhash': 'xxhash', } From c16cd433e0c53c89a6891df76cdce165d3fcffa4 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Tue, 15 Sep 2026 19:48:08 +0800 Subject: [PATCH 55/56] refactor(storage): enforce xxHash3-128 and remove sha256 fallback in MediaCache - Import xxhash directly instead of conditional try-except block - Remove unused hashlib import and sha256 fallback branch in hash_bytes - Update docstrings to reflect pure xxHash3-128 content-addressing --- src/langbot/pkg/storage/media.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/langbot/pkg/storage/media.py b/src/langbot/pkg/storage/media.py index 74d882da1..0029013ff 100644 --- a/src/langbot/pkg/storage/media.py +++ b/src/langbot/pkg/storage/media.py @@ -4,18 +4,13 @@ import asyncio import base64 import copy import datetime -import hashlib import mimetypes import os import re +import xxhash from pathlib import Path from typing import TYPE_CHECKING, Any -try: - import xxhash -except ImportError: - xxhash = None - if TYPE_CHECKING: from ...core import app from . import mgr as storage_mgr @@ -29,7 +24,7 @@ SAFE_MEDIA_FILENAME = re.compile(r'^[a-f0-9]{32,64}(\.[a-zA-Z0-9]{1,10})?$') class MediaCache: """Content-addressable storage cache for images and media attachments. - Deduplicates media files using xxHash3-128 (with sha256 fallback), + Deduplicates media files using xxHash3-128, offloads payloads from SQLite to StorageProvider, and implements LRU and age-based retention cleanup. """ @@ -41,9 +36,7 @@ class MediaCache: @staticmethod def hash_bytes(data: bytes) -> str: """Compute content-addressable hash for binary data.""" - if xxhash is not None: - return xxhash.xxh3_128_hexdigest(data) - return hashlib.sha256(data).hexdigest()[:32] + return xxhash.xxh3_128_hexdigest(data) @staticmethod def parse_data_url(data_url: str) -> tuple[bytes, str] | None: From 7e239f162942059a66a705a6af0720778262704b Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Tue, 15 Sep 2026 20:30:34 +0800 Subject: [PATCH 56/56] fix(maintenance): guard media cache cleanup and annotate exception suppression - Safely resolve media_cache via getattr in cleanup_expired_files to prevent AttributeError when storage_mgr is unset in cloud/test fixtures - Only attach 'media_files' in cleanup return dictionary when media cache is active on singleton, preserving exact return contract for cloud maintenance tests - Add explanatory comments to exception suppression in MediaCache.touch and cleanup loops to address code-quality review findings --- src/langbot/pkg/api/http/service/maintenance.py | 14 +++++++++----- src/langbot/pkg/storage/media.py | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index 562c17d61..3ba4e77c5 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -91,25 +91,29 @@ class MaintenanceService: 0, 'storage.media_cache.max_size_mb', ) + media_cache = getattr(getattr(self.ap, 'storage_mgr', None), 'media_cache', None) + is_singleton = await self._is_oss_singleton(context) media_cleanup = ( - await self.ap.storage_mgr.media_cache.cleanup( + await media_cache.cleanup( media_retention_days, media_max_size_mb, ) - if hasattr(self.ap.storage_mgr, 'media_cache') and await self._is_oss_singleton(context) + if media_cache is not None and is_singleton else {} ) - return { + result = { 'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days), 'log_files': await asyncio.to_thread( self._cleanup_expired_log_files, log_retention_days, ) - if await self._is_oss_singleton(context) + if is_singleton else 0, - 'media_files': media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0), } + if media_cache is not None and is_singleton: + result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0) + return result async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]: require_workspace_uuid(context) diff --git a/src/langbot/pkg/storage/media.py b/src/langbot/pkg/storage/media.py index 0029013ff..8f5537fb3 100644 --- a/src/langbot/pkg/storage/media.py +++ b/src/langbot/pkg/storage/media.py @@ -120,6 +120,7 @@ class MediaCache: try: await asyncio.to_thread(os.utime, full_path, (now, now)) except Exception: + # Failures to update mtime are intentionally ignored because LRU touch is opportunistic. pass async def cleanup( @@ -166,6 +167,7 @@ class MediaCache: expired_deleted += 1 bytes_freed += stat.st_size except OSError: + # Best-effort cleanup; file may already be gone or temporarily inaccessible. pass else: remaining.append((entry, stat.st_size, stat.st_mtime)) @@ -184,7 +186,8 @@ class MediaCache: bytes_freed += size total_bytes -= size except OSError: - pass + # Best-effort cleanup; file may already be gone or temporarily inaccessible. + continue return { 'expired_deleted': expired_deleted,