diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index f48ffd5ae7..371605b533 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -20,7 +20,14 @@ from open_webui.models.groups import Groups from open_webui.utils.access_control import has_connection_access from open_webui.utils.auth import get_verified_user from open_webui.utils.json_codec import JSONCodec -from open_webui.utils.terminals import get_terminal_server_url +from open_webui.utils.terminals import ( + TERMINAL_CONTEXT_HEADER, + get_terminal_server_url, + terminal_context_available, + terminal_context_config, + terminal_context_id, + terminal_contexts, +) from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token from starlette.background import BackgroundTask @@ -78,6 +85,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user 'id': connection.get('id', ''), 'url': connection.get('url', ''), 'name': connection.get('name', ''), + 'contexts': terminal_contexts(connection), } for connection in connections if connection.get('enabled', True) and await has_connection_access(user, connection, user_group_ids) @@ -126,6 +134,13 @@ async def proxy_terminal( session_id = request.headers.get('x-session-id') if session_id: headers['X-Session-Id'] = session_id + if not terminal_context_available(connection, 'chat'): + return JSONResponse({'error': 'Terminal server is not available in chats'}, status_code=403) + context_id = terminal_context_id(connection, {'chat_id': session_id}, 'chat') + if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: + return JSONResponse({'error': 'A saved chat is required for this terminal'}, status_code=409) + if context_id: + headers[TERMINAL_CONTEXT_HEADER] = context_id cookies = {} auth_type = connection.get('auth_type', 'bearer') @@ -218,8 +233,8 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): The client must send ``{"type": "auth", "token": ""}`` as its first message after connecting. - Returns ``(user, connection)`` on success, or ``None`` after closing *ws* - with an appropriate error code. + Returns ``(user, connection, chat_id)`` on success, or ``None`` after + closing *ws* with an appropriate error code. """ import asyncio @@ -260,7 +275,11 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): await ws.close(code=4003, reason='Access denied') return None - return user, connection + chat_id = payload.get('chat_id', '') + if not terminal_context_available(connection, 'chat'): + await ws.close(code=4003, reason='Terminal server is not available in chats') + return None + return user, connection, chat_id if isinstance(chat_id, str) else '' @router.websocket('/{server_id}/api/terminals/{session_id}') @@ -280,7 +299,7 @@ async def ws_terminal( result = await _resolve_authenticated_connection(ws, server_id) if result is None: return - user, connection = result + user, connection, chat_id = result base_url = get_terminal_server_url(connection) if not base_url: @@ -293,6 +312,13 @@ async def ws_terminal( upstream_params = {} # For orchestrator-backed servers, pass user_id upstream_params['user_id'] = user.id + context_id = terminal_context_id(connection, {'chat_id': chat_id}, 'chat') + upstream_headers = {} + if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: + await ws.close(code=4003, reason='A saved chat is required for this terminal') + return + if context_id: + upstream_headers[TERMINAL_CONTEXT_HEADER] = context_id import urllib.parse @@ -308,7 +334,11 @@ async def ws_terminal( opened = False session = aiohttp.ClientSession() try: - async with session.ws_connect(upstream_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as upstream: + async with session.ws_connect( + upstream_url, + headers=upstream_headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as upstream: import asyncio import json as _json diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py index e97348a8d5..2ca2579c4d 100644 --- a/backend/open_webui/utils/terminals.py +++ b/backend/open_webui/utils/terminals.py @@ -2,6 +2,18 @@ from urllib.parse import quote +from open_webui.utils.chat_id import is_saved_chat_id + +TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id' +TERMINAL_CONTEXT_DEFAULT = 'default' +TERMINAL_CONTEXT_TYPES = {'chat', 'automation'} +TERMINAL_CONTEXT_ID_SOURCES = {'chat': 'chat_id', 'automation': 'automation_id'} + + +def is_terminal_orchestrator(connection: dict) -> bool: + """Return whether this connection points at Terminals, not raw Open Terminal.""" + return connection.get('server_type') == 'orchestrator' or bool(connection.get('policy_id')) + def get_terminal_server_url(connection: dict) -> str: """Return the upstream base URL for a terminal connection. @@ -14,3 +26,81 @@ def get_terminal_server_url(connection: dict) -> str: if policy_id: return f'{base_url}/p/{quote(policy_id, safe="")}' return base_url + + +def terminal_context_config(connection: dict, context: str) -> dict | bool: + """Return config for an OpenWebUI terminal context. + + Missing config is legacy behavior: available, shared default terminal. + """ + if not is_terminal_orchestrator(connection): + return {} + + contexts = (connection.get('config') or {}).get('contexts') + if not isinstance(contexts, dict): + return {} + + value = contexts.get(context, {}) + if value is False: + return False + return value if isinstance(value, dict) else {} + + +def terminal_context_available(connection: dict, context: str) -> bool: + """Return whether this terminal is exposed in an OpenWebUI context.""" + if context not in TERMINAL_CONTEXT_TYPES: + return False + return terminal_context_config(connection, context) is not False + + +def terminal_context_id( + connection: dict, + metadata: dict | None = None, + context: str = 'chat', +) -> str | None: + """Return the terminal runtime context for trusted request metadata.""" + if not is_terminal_orchestrator(connection) or not terminal_context_available(connection, context): + return None + + config = terminal_context_config(connection, context) + context_id_source = config.get('context_id') if isinstance(config, dict) else None + if not context_id_source or context_id_source == TERMINAL_CONTEXT_DEFAULT: + return None + + if context_id_source != TERMINAL_CONTEXT_ID_SOURCES.get(context): + return None + + metadata = metadata or {} + + if context == 'automation': + automation_id = metadata.get('automation_id') + return f'automation:{automation_id}' if automation_id else None + + chat_id = metadata.get('chat_id') + if context == 'chat' and chat_id and is_saved_chat_id(chat_id): + return f'chat:{chat_id}' + return None + + +def terminal_contexts(connection: dict) -> dict: + """Return normalized sparse context config for clients.""" + if not is_terminal_orchestrator(connection): + return {} + + contexts = (connection.get('config') or {}).get('contexts') + if not isinstance(contexts, dict): + return {} + + result = {} + for context, value in contexts.items(): + if context not in TERMINAL_CONTEXT_TYPES: + continue + if value is False: + result[context] = False + elif isinstance(value, dict): + context_id_source = value.get('context_id') + if context_id_source in {TERMINAL_CONTEXT_DEFAULT, TERMINAL_CONTEXT_ID_SOURCES[context]}: + result[context] = {'context_id': context_id_source} + else: + result[context] = {} + return result diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index efb80f2a11..7e44d28820 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -106,7 +106,13 @@ from open_webui.utils.headers import get_custom_headers, include_user_info_heade from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import is_string_allowed from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id -from open_webui.utils.terminals import get_terminal_server_url +from open_webui.utils.terminals import ( + TERMINAL_CONTEXT_HEADER, + get_terminal_server_url, + terminal_context_available, + terminal_context_config, + terminal_context_id, +) from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo @@ -1361,10 +1367,25 @@ async def get_terminal_tools( # Use chat_id as the per-session key for cwd tracking metadata = extra_params.get('__metadata__', {}) + terminal_context = 'automation' if metadata.get('automation_id') else 'chat' + if not terminal_context_available(connection, terminal_context): + raise RuntimeError(f"Terminal server '{terminal_id}' is not available for {terminal_context}") + session_id = metadata.get('chat_id') if session_id: headers['X-Session-Id'] = session_id + context_id = terminal_context_id(connection, metadata, terminal_context) + config = terminal_context_config(connection, terminal_context) + if ( + isinstance(config, dict) + and config.get('context_id') in {'chat_id', 'automation_id'} + and not context_id + ): + raise RuntimeError(f"Terminal server '{terminal_id}' requires a saved {terminal_context} context") + if context_id: + headers[TERMINAL_CONTEXT_HEADER] = context_id + # Fetch live with the user's credentials so prompt changes apply without a restart terminal_cwd, system_prompt = await asyncio.gather( get_terminal_cwd(server_data['url'], headers, cookies), diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index eedbcc3eed..4e4a5a5ab6 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -36,6 +36,7 @@ export type TerminalServer = { id: string; url: string; name: string; + contexts?: Record; }; export const getTerminalServers = async (token: string): Promise => { diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte index 0cf622b86f..e9fccfecf5 100644 --- a/src/lib/components/AddTerminalServerModal.svelte +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -36,7 +36,10 @@ let auth_type = 'bearer'; let path = '/openapi.json'; let enabled = false; + let chatContextMode: 'default' | 'chat_id' | 'off' = 'default'; + let automationContextMode: 'default' | 'automation_id' | 'off' = 'default'; let showAdvanced = false; + let showOrchestratorAdvanced = false; let showAccessControlModal = false; let showDeleteConfirmDialog = false; let accessGrants: any[] = []; @@ -82,6 +85,19 @@ // Restore policy state serverType = connection?.server_type ?? (connection?.policy_id ? 'orchestrator' : null); policyId = connection?.policy_id ?? ''; + const contexts = serverType === 'orchestrator' ? (connection?.config?.contexts ?? {}) : {}; + chatContextMode = + contexts?.chat === false + ? 'off' + : contexts?.chat?.context_id === 'chat_id' + ? 'chat_id' + : 'default'; + automationContextMode = + contexts?.automation === false + ? 'off' + : contexts?.automation?.context_id === 'automation_id' + ? 'automation_id' + : 'default'; const p: Record = {}; policyImage = p.image ?? ''; @@ -110,6 +126,8 @@ path = '/openapi.json'; enabled = false; accessGrants = []; + chatContextMode = 'default'; + automationContextMode = 'default'; serverType = null; policyId = ''; @@ -347,6 +365,15 @@ } } + const contexts: Record = {}; + if (chatContextMode === 'off') contexts.chat = false; + else if (chatContextMode === 'chat_id') contexts.chat = { context_id: 'chat_id' }; + if (automationContextMode === 'off') contexts.automation = false; + else if (automationContextMode === 'automation_id') { + contexts.automation = { context_id: 'automation_id' }; + } + const useContexts = !direct && serverType === 'orchestrator' && Object.keys(contexts).length > 0; + const result = { ...(!direct && id.trim() ? { id: id.trim() } : {}), url, @@ -356,7 +383,8 @@ auth_type, enabled: enabled, config: { - ...(!direct ? { access_grants: accessGrants } : {}) + ...(!direct ? { access_grants: accessGrants } : {}), + ...(useContexts ? { contexts } : {}) }, // Policy fields ...(serverType ? { server_type: serverType } : {}), @@ -506,6 +534,59 @@ {#if serverType === 'orchestrator' && !direct} + + + {#if showOrchestratorAdvanced} +
+
+
+
+ {$i18n.t('Terminal Contexts')} +
+
+
+ + + + +
+
+
+
@@ -727,6 +808,7 @@ {refreshing ? $i18n.t('Refreshing...') : $i18n.t('Refresh Terminals')}
+ {/if} {/if}
diff --git a/src/lib/components/admin/Settings/Integrations.svelte b/src/lib/components/admin/Settings/Integrations.svelte index df43d0178c..d0c5034af6 100644 --- a/src/lib/components/admin/Settings/Integrations.svelte +++ b/src/lib/components/admin/Settings/Integrations.svelte @@ -92,7 +92,8 @@ id: t.id, url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`, name: t.name, - key: localStorage.token + key: localStorage.token, + contexts: t.contexts ?? {} })); terminalServers.set([...existingDirectTerminals, ...systemEntries] as any); } diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index 8344e51c18..1c3ba48eda 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -34,6 +34,7 @@ import FileNav from './FileNav.svelte'; import PyodideFileNav from './PyodideFileNav.svelte'; import Overview from './Overview.svelte'; + import { isSavedChatId } from '$lib/utils/chatId'; const i18n = getContext('i18n'); @@ -72,11 +73,26 @@ $: hasMessages = history?.messages && Object.keys(history.messages).length > 0; $: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true); - $: showFilesTab = + const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {}; + const chatContextAvailable = (terminal: any) => chatContext(terminal) !== false; + const chatContextNeedsSavedChat = (terminal: any) => + chatContext(terminal)?.context_id === 'chat_id'; + $: selectedSystemTerminal = ($terminalServers ?? []).find( + (t) => t.id && t.id === $selectedTerminalId + ); + $: selectedSystemTerminalAvailable = + selectedSystemTerminal && + chatContextAvailable(selectedSystemTerminal) && + !(chatContextNeedsSavedChat(selectedSystemTerminal) && !isSavedChatId(chatId)); + $: terminalFilesAvailable = !!( ($selectedTerminalId && - (($terminalServers ?? []).some((t) => t.id && t.id === $selectedTerminalId) || - $user?.role === 'admin' || - ($user?.permissions?.features?.direct_tool_servers ?? true))) || + (selectedSystemTerminalAvailable || + (!selectedSystemTerminal && + ($user?.role === 'admin' || + ($user?.permissions?.features?.direct_tool_servers ?? true))))) + ); + $: showFilesTab = + terminalFilesAvailable || (codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter'); $: showOverviewTab = hasMessages; @@ -94,13 +110,13 @@ } // Auto-switch to Files tab when display_file is triggered - $: if ($showFileNavPath) { + $: if ($showFileNavPath && terminalFilesAvailable) { activeTab = 'files'; showControls.set(true); } // Auto-open Files tab when a terminal is selected (suppress panel open when full-screen) - $: if ($selectedTerminalId && showFilesTab) { + $: if ($selectedTerminalId && terminalFilesAvailable) { activeTab = 'files'; if (largeScreen) { showControls.set($settings?.showFilesOnTerminalSelect ?? true); @@ -374,7 +390,7 @@ showMessage(node.data.message, true); }} /> - {:else if activeTab === 'files' && $selectedTerminalId} + {:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId} {:else if activeTab === 'files' && codeInterpreterEnabled} @@ -523,7 +539,7 @@ showMessage(node.data.message, true); }} /> - {:else if activeTab === 'files' && $selectedTerminalId} + {:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId} {:else if activeTab === 'files' && codeInterpreterEnabled} diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 247d8fc3a3..5dda6f42cd 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -34,6 +34,7 @@ import Document from '../icons/Document.svelte'; import PenAlt from '../icons/PenAlt.svelte'; import ZoomReset from '../icons/ZoomReset.svelte'; + import { isSavedChatId, isTemporaryChatId } from '$lib/utils/chatId'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; @@ -231,11 +232,22 @@ // ── Terminal resolution ────────────────────────────────────────────── let selectedTerminal: { url: string; key: string } | null = null; + let terminalChatContextPending = false; + let terminalChatContextHidden = false; + + const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {}; const getTerminal = (): { url: string; key: string } | null => { const systemTerminal = $selectedTerminalId ? (($terminalServers ?? []).find((t) => t.id === $selectedTerminalId) ?? null) : ($terminalServers?.[0] ?? null); + const chatConfig = chatContext(systemTerminal); + const chatScoped = !!systemTerminal && chatConfig?.context_id === 'chat_id'; + terminalChatContextHidden = + !!systemTerminal && (chatConfig === false || (chatScoped && isTemporaryChatId(chatId))); + terminalChatContextPending = + chatScoped && !terminalChatContextHidden && !isSavedChatId(chatId); + if (terminalChatContextHidden || terminalChatContextPending) return null; const userTerminal = ($settings?.terminalServers ?? []).find( (s) => s.url === $selectedTerminalId @@ -929,7 +941,13 @@ const onBlur = () => (shiftKey = false); const onVisibilityChange = () => { - if (document.visibilityState === 'visible' && !selectedFile && selectedTerminal && !loading) { + if ( + document.visibilityState === 'visible' && + !selectedFile && + selectedTerminal && + !terminalChatContextPending && + !loading + ) { loadDir(currentPath); } }; @@ -972,7 +990,16 @@ -{#if !selectedTerminal} +{#if terminalChatContextHidden} + +{:else if terminalChatContextPending} +
+ +
+ {$i18n.t('Start the chat to use this terminal.')} +
+
+{:else if !selectedTerminal}
diff --git a/src/lib/components/chat/MessageInput/TerminalMenu.svelte b/src/lib/components/chat/MessageInput/TerminalMenu.svelte index 1291f749da..dc13294210 100644 --- a/src/lib/components/chat/MessageInput/TerminalMenu.svelte +++ b/src/lib/components/chat/MessageInput/TerminalMenu.svelte @@ -1,8 +1,16 @@
-
+
diff --git a/src/lib/utils/chatId.ts b/src/lib/utils/chatId.ts index 4772f73a25..6c5f6da6f0 100644 --- a/src/lib/utils/chatId.ts +++ b/src/lib/utils/chatId.ts @@ -1,5 +1,6 @@ const TEMPORARY_CHAT_ID_PREFIX = 'temporary:'; const LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:'; // Legacy temporary chat prefix. +const CHANNEL_CHAT_ID_PREFIX = 'channel:'; export const createTemporaryChatId = (sessionId: string | undefined) => `${TEMPORARY_CHAT_ID_PREFIX}${sessionId}`; @@ -8,3 +9,6 @@ export const isTemporaryChatId = (chatId: string | null | undefined) => !!chatId && (chatId.startsWith(TEMPORARY_CHAT_ID_PREFIX) || chatId.startsWith(LEGACY_TEMPORARY_CHAT_ID_PREFIX)); + +export const isSavedChatId = (chatId: string | null | undefined) => + !!chatId && !isTemporaryChatId(chatId) && !chatId.startsWith(CHANNEL_CHAT_ID_PREFIX); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 71bbd80ec7..643a350588 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -181,7 +181,8 @@ id: t.id, url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`, name: t.name, - key: localStorage.token + key: localStorage.token, + contexts: t.contexts ?? {} })) ]); };