CVE-2026-45350
Description
Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. Prior to 0.8.6, there is a vulnerability in chat completion API, which allows attackers to bypass tool restrictions, potentially enabling unauthorized actions or access. In the chat_completion API, the parameters tool_ids and tool_servers are supplied by the user. These parameters are used to create a tools_dict by the middleware. This is then used by get_tool_by_id to retrieve the appropriate tool. However, there is no checks in that ensures the user that uses the API has permission to use the tool, meaning that a user can invoke any server tool by supplying the correct tool_id or tool_servers parameters via the chat completion API. Moreover, the authentication token stored in the server would be used when invoking the tool, so the tool will be invoked with the server privilege. This vulnerability is fixed in 0.8.6.
Affected products
1- Range: <= 0.8.5
Patches
14737e1f11feat: open terminal integration
15 files changed · +767 −64
backend/open_webui/config.py+14 −0 modified@@ -1183,6 +1183,20 @@ def reachable(host: str, port: int) -> bool: tool_server_connections, ) +#################################### +# TERMINAL_SERVER +#################################### + +terminal_server_connections = json.loads( + os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]") +) + +TERMINAL_SERVER_CONNECTIONS = PersistentConfig( + "TERMINAL_SERVER_CONNECTIONS", + "terminal_server.connections", + terminal_server_connections, +) + #################################### # WEBUI ####################################
backend/open_webui/main.py+19 −2 modified@@ -96,6 +96,7 @@ users, utils, scim, + terminals, ) from open_webui.routers.retrieval import ( @@ -132,6 +133,8 @@ THREAD_POOL_SIZE, # Tool Server Configs TOOL_SERVER_CONNECTIONS, + # Terminal Server + TERMINAL_SERVER_CONNECTIONS, # Code Execution ENABLE_CODE_EXECUTION, CODE_EXECUTION_ENGINE, @@ -524,7 +527,7 @@ process_chat_payload, process_chat_response, ) -from open_webui.utils.tools import set_tool_servers +from open_webui.utils.tools import set_tool_servers, set_terminal_servers from open_webui.utils.auth import ( get_license_data, @@ -690,8 +693,11 @@ async def lifespan(app: FastAPI): ) await set_tool_servers(mock_request) log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)") + + await set_terminal_servers(mock_request) + log.info(f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)") except Exception as e: - log.warning(f"Failed to initialize tool servers at startup: {e}") + log.warning(f"Failed to initialize tool/terminal servers at startup: {e}") yield @@ -775,6 +781,15 @@ async def lifespan(app: FastAPI): app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS app.state.TOOL_SERVERS = [] +######################################## +# +# TERMINAL SERVER +# +######################################## + +app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS +app.state.TERMINAL_SERVERS = [] + ######################################## # # DIRECT CONNECTIONS @@ -1540,6 +1555,7 @@ async def inspect_websocket(request: Request, call_next): if ENABLE_ADMIN_ANALYTICS: app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"]) # SCIM 2.0 API for identity management if ENABLE_SCIM: @@ -2204,6 +2220,7 @@ async def get_app_config(request: Request): "pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT, "response_watermark": app.state.config.RESPONSE_WATERMARK, }, + "license_metadata": app.state.LICENSE_METADATA, **( {
backend/open_webui/routers/configs.py+40 −0 modified@@ -15,6 +15,7 @@ get_tool_server_data, get_tool_server_url, set_tool_servers, + set_terminal_servers, ) from open_webui.utils.mcp.client import MCPClient from open_webui.models.oauth_sessions import OAuthSessions @@ -214,6 +215,45 @@ async def set_tool_servers_config( } +class TerminalServerConnection(BaseModel): + id: str + url: str + key: Optional[str] = "" + name: Optional[str] = "" + auth_type: Optional[str] = "bearer" + config: Optional[dict] = None # holds access_grants, etc. + + model_config = ConfigDict(extra="allow") + + +class TerminalServersConfigForm(BaseModel): + TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection] + + +@router.get("/terminal_servers") +async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)): + return { + "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + } + + +@router.post("/terminal_servers") +async def set_terminal_servers_config( + request: Request, + form_data: TerminalServersConfigForm, + user=Depends(get_admin_user), +): + request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [ + connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS + ] + + await set_terminal_servers(request) + + return { + "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + } + + @router.post("/tool_servers/verify") async def verify_tool_servers_config( request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
backend/open_webui/routers/terminals.py+137 −0 added@@ -0,0 +1,137 @@ +"""Reverse proxy for admin-configured terminal servers. + +Routes: + GET / — list terminals the user has access to + * /{server_id}/{path:path} — proxy request to terminal server +""" + +import logging + +import aiohttp +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.background import BackgroundTask + +from open_webui.utils.auth import get_verified_user +from open_webui.utils.access_control import has_connection_access +from open_webui.models.groups import Groups + +log = logging.getLogger(__name__) + +router = APIRouter() + +STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf") +STRIPPED_RESPONSE_HEADERS = frozenset( + ("transfer-encoding", "connection", "content-encoding", "content-length") +) + + +@router.get("/") +async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): + """Return terminal servers the authenticated user has access to.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + + return [ + {"id": connection.get("id", ""), "url": connection.get("url", ""), "name": connection.get("name", "")} + for connection in connections + if has_connection_access(user, connection, user_group_ids) + ] + + +PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + + +@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS) +async def proxy_terminal( + server_id: str, + path: str, + request: Request, + user=Depends(get_verified_user), +): + """Proxy a request to the admin terminal server identified by *server_id*.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get("id") == server_id), None) + + if connection is None: + return JSONResponse({"error": f"Terminal server '{server_id}' not found"}, status_code=404) + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + return JSONResponse({"error": "Access denied"}, status_code=403) + + base_url = (connection.get("url") or "").rstrip("/") + if not base_url: + return JSONResponse({"error": "Terminal server URL not configured"}, status_code=503) + + target_url = f"{base_url}/{path}" + if request.query_params: + target_url += f"?{request.query_params}" + + headers = {"X-User-Id": user.id} + cookies = {} + auth_type = connection.get("auth_type", "bearer") + + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {connection.get('key', '')}" + elif auth_type == "session": + cookies = request.cookies + headers["Authorization"] = f"Bearer {request.state.token.credentials}" + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = request.headers.get("x-oauth-access-token", "") + if oauth_token: + headers["Authorization"] = f"Bearer {oauth_token}" + # auth_type == "none": no Authorization header + + content_type = request.headers.get("content-type") + if content_type: + headers["Content-Type"] = content_type + + body = await request.body() + session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=300, connect=10), + trust_env=True, + ) + + try: + upstream_response = await session.request( + method=request.method, + url=target_url, + headers=headers, + cookies=cookies, + data=body or None, + ) + + upstream_content_type = upstream_response.headers.get("content-type", "") + filtered_headers = { + key: value + for key, value in upstream_response.headers.items() + if key.lower() not in STRIPPED_RESPONSE_HEADERS + } + + # Stream binary responses directly + if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): + async def cleanup(): + await upstream_response.release() + await session.close() + + return StreamingResponse( + content=upstream_response.content.iter_any(), + status_code=upstream_response.status, + headers=filtered_headers, + background=BackgroundTask(cleanup), + ) + + # Buffer text/JSON responses + response_body = await upstream_response.read() + status_code = upstream_response.status + await upstream_response.release() + await session.close() + + return Response(content=response_body, status_code=status_code, headers=filtered_headers) + + except Exception as error: + await session.close() + log.exception("Terminal proxy error: %s", error) + return JSONResponse({"error": f"Terminal proxy error: {error}"}, status_code=502)
backend/open_webui/utils/access_control.py+28 −0 modified@@ -153,6 +153,34 @@ def has_access( return False +def has_connection_access( + user: UserModel, + connection: dict, + user_group_ids: Optional[Set[str]] = None, +) -> bool: + """ + Check if a user can access a server connection (tool server, terminal, etc.) + based on ``config.access_grants`` within the connection dict. + + - Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed + - Empty / missing access_grants → allowed for all users + - Otherwise → delegates to ``has_access`` + """ + from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL + + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return True + + if user_group_ids is None: + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + + access_grants = (connection.get("config") or {}).get("access_grants", []) + if not access_grants: + return True + + return has_access(user.id, "read", access_grants, user_group_ids) + + def migrate_access_control( data: dict, ac_key: str = "access_control", grants_key: str = "access_grants" ) -> None:
backend/open_webui/utils/middleware.py+16 −2 modified@@ -99,8 +99,9 @@ from open_webui.utils.tools import ( get_tools, get_updated_tool_function, - has_tool_server_access, + get_terminal_tools, ) +from open_webui.utils.access_control import has_connection_access from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -2225,6 +2226,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) tool_ids = form_data.pop("tool_ids", None) + terminal_id = form_data.pop("terminal_id", None) files = form_data.pop("files", None) # Caller-provided OpenAI-style tools take precedence over server-side @@ -2298,6 +2300,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata = { **metadata, "tool_ids": tool_ids, + "terminal_id": terminal_id, "files": files, } form_data["metadata"] = metadata @@ -2342,7 +2345,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): continue # Check access control for MCP server - if not has_tool_server_access(user, mcp_server_connection): + if not has_connection_access(user, mcp_server_connection): log.warning( f"Access denied to MCP server {server_id} for user {user.id}" ) @@ -2479,6 +2482,17 @@ async def tool_function(**kwargs): if mcp_tools_dict: tools_dict = {**tools_dict, **mcp_tools_dict} + # Resolve terminal tools if terminal_id is set + if terminal_id: + terminal_tools = await get_terminal_tools( + request, + terminal_id, + user, + extra_params, + ) + if terminal_tools: + tools_dict = {**tools_dict, **terminal_tools} + if direct_tool_servers: for tool_server in direct_tool_servers: tool_specs = tool_server.pop("specs", [])
backend/open_webui/utils/tools.py+188 −17 modified@@ -40,7 +40,7 @@ from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrants from open_webui.utils.plugin import load_tool_module_by_id -from open_webui.utils.access_control import has_access +from open_webui.utils.access_control import has_access, has_connection_access from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, @@ -144,19 +144,6 @@ def get_updated_tool_function(function: Callable, extra_params: dict): return function -def has_tool_server_access( - user: UserModel, server_connection: dict, user_group_ids: set = None -) -> bool: - """Check if user has access to a tool server (MCP or OpenAPI).""" - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - return True - - if user_group_ids is None: - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - - server_config = server_connection.get("config", {}) - access_grants = server_config.get("access_grants", []) - return has_access(user.id, "read", access_grants, user_group_ids) async def get_tools( @@ -297,7 +284,7 @@ async def get_tools( ) # Check access control for tool server - if not has_tool_server_access( + if not has_connection_access( user, tool_server_connection, user_group_ids ): log.warning( @@ -394,7 +381,7 @@ async def tool_function(**kwargs): tool_dict = { "tool_id": tool_id, "callable": callable, - "spec": spec, + "spec": clean_openai_tool_schema(spec), # Misc info "type": "external", } @@ -810,14 +797,19 @@ def convert_openapi_to_tool_payload(openapi_spec): f". Possible values: {', '.join(param_schema.get('enum'))}" ) param_property = { - "type": param_schema.get("type"), + "type": param_schema.get("type") or "string", "description": description, } # Include items property for array types (required by OpenAI) if param_schema.get("type") == "array" and "items" in param_schema: param_property["items"] = param_schema["items"] + # Filter out None values to prevent schema validation errors + param_property = { + k: v for k, v in param_property.items() if v is not None + } + tool["parameters"]["properties"][param_name] = param_property if param.get("required"): tool["parameters"]["required"].append(param_name) @@ -881,6 +873,180 @@ async def get_tool_servers(request: Request): return tool_servers +async def get_terminal_cwd( + base_url: str, + headers: dict, + cookies: Optional[dict] = None, +) -> Optional[str]: + """Fetch the current working directory from a terminal server.""" + try: + cwd_url = f"{base_url.rstrip('/')}/files/cwd" + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=5), + trust_env=True, + ) as session: + async with session.get( + cwd_url, headers=headers, cookies=cookies or {} + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("cwd") + except Exception as e: + log.debug(f"Failed to fetch terminal CWD: {e}") + return None + + +async def set_terminal_servers(request: Request): + """Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + + # Build server configs with info containing the connection ID + server_configs = [] + for connection in connections: + conn_id = connection.get("id", "") + if not connection.get("url"): + continue + + auth_type = connection.get("auth_type", "bearer") + token = None + if auth_type == "bearer": + token = connection.get("key", "") + + server_configs.append({ + "url": connection.get("url", ""), + "key": token or "", + "auth_type": auth_type, + "path": "openapi.json", + "spec_type": "url", + "config": {"enable": True}, + "info": {"id": conn_id, "name": connection.get("name", "")}, + }) + + request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs) + + if request.app.state.redis is not None: + await request.app.state.redis.set( + "terminal_servers", json.dumps(request.app.state.TERMINAL_SERVERS) + ) + + return request.app.state.TERMINAL_SERVERS + + +async def get_terminal_servers(request: Request): + """Return cached terminal server specs, loading if needed.""" + terminal_servers = [] + if request.app.state.redis is not None: + try: + terminal_servers = json.loads( + await request.app.state.redis.get("terminal_servers") + ) + request.app.state.TERMINAL_SERVERS = terminal_servers + except Exception as e: + log.error(f"Error fetching terminal_servers from Redis: {e}") + + if not terminal_servers: + terminal_servers = await set_terminal_servers(request) + + return terminal_servers + + +async def get_terminal_tools( + request: Request, + terminal_id: str, + user: UserModel, + extra_params: dict, +) -> dict[str, dict]: + """Resolve tools for a terminal server identified by terminal_id. + + - Finds the connection in TERMINAL_SERVER_CONNECTIONS + - Checks access_grants + - Loads specs from cache + - Builds callables that route through the terminal proxy + """ + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next( + (c for c in connections if c.get("id") == terminal_id), None + ) + if connection is None: + log.warning(f"Terminal server not found: {terminal_id}") + return {} + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + log.warning(f"Access denied to terminal {terminal_id} for user {user.id}") + return {} + + # Find the cached spec data for this terminal + terminal_servers = await get_terminal_servers(request) + server_data = next( + (s for s in terminal_servers if s.get("id") == terminal_id), None + ) + if server_data is None: + log.warning(f"Terminal server spec not found for {terminal_id}") + return {} + + specs = server_data.get("specs", []) + if not specs: + return {} + + # Build auth headers + auth_type = connection.get("auth_type", "bearer") + cookies = {} + headers = {"Content-Type": "application/json", "X-User-Id": user.id} + + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {connection.get('key', '')}" + elif auth_type == "session": + cookies = request.cookies + headers["Authorization"] = f"Bearer {request.state.token.credentials}" + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = f"Bearer {oauth_token.get('access_token', '')}" + # auth_type == "none": no Authorization header + + terminal_cwd = await get_terminal_cwd( + connection.get("url", ""), headers, cookies + ) + + tools_dict = {} + for spec in specs: + function_name = spec["name"] + + # Inject CWD into run_command description + tool_spec = clean_openai_tool_schema(spec) + if function_name == "run_command" and terminal_cwd: + tool_spec["description"] = ( + tool_spec.get("description", "") + + f"\n\nThe current working directory is: {terminal_cwd}" + ) + + def make_tool_function(fn_name, srv_data, hdrs, cks): + async def tool_function(**kwargs): + return await execute_tool_server( + url=srv_data["url"], + headers=hdrs, + cookies=cks, + name=fn_name, + params=kwargs, + server_data=srv_data, + ) + return tool_function + + tool_function = make_tool_function(function_name, server_data, headers, cookies) + callable = get_async_tool_function_and_apply_extra_params(tool_function, {}) + + tools_dict[function_name] = { + "tool_id": f"terminal:{terminal_id}", + "callable": callable, + "spec": tool_spec, + "type": "terminal", + } + + return tools_dict + + async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]: _headers = { "Accept": "application/json", @@ -997,6 +1163,11 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, log.error(f"Failed to connect to {url} OpenAPI tool server") continue + # Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs) + if not isinstance(response, dict) or "paths" not in response: + log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'") + continue + response = { "openapi": response, "info": response.get("info", {}),
src/lib/apis/configs/index.ts+57 −0 modified@@ -172,6 +172,63 @@ export const setToolServerConnections = async (token: string, connections: objec return res; }; +export const getTerminalServerConnections = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const setTerminalServerConnections = async (token: string, connections: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connections + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const verifyToolServerConnection = async (token: string, connection: object) => { let error = null;
src/lib/apis/terminal/index.ts+20 −0 modified@@ -5,6 +5,26 @@ export type FileEntry = { modified?: number; }; +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type TerminalServer = { + id: string; + url: string; + name: string; +}; + +export const getTerminalServers = async ( + token: string +): Promise<TerminalServer[]> => { + const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, { + headers: { + Authorization: `Bearer ${token}` + } + }).catch(() => null); + if (!res || !res.ok) return []; + return res.json().catch(() => []); +}; + export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; const res = await fetch(url, {
src/lib/components/AddTerminalServerModal.svelte+11 −1 modified@@ -11,6 +11,7 @@ export let show = false; export let edit = false; + export let admin = false; export let connection: { url: string; key: string; @@ -236,7 +237,10 @@ > <option value="none">{$i18n.t('None')}</option> <option value="bearer">{$i18n.t('Bearer')}</option> - <option value="session">{$i18n.t('Session')}</option> + {#if admin} + <option value="session">{$i18n.t('Session')}</option> + <option value="system_oauth">{$i18n.t('OAuth')}</option> + {/if} </select> </div> @@ -259,6 +263,12 @@ > {$i18n.t('Forwards system user session credentials to authenticate')} </div> + {:else if auth_type === 'system_oauth'} + <div + class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} + > + {$i18n.t('Forwards system user OAuth access token to authenticate')} + </div> {/if} </div> </div>
src/lib/components/admin/Settings/Integrations.svelte+189 −7 modified@@ -1,6 +1,7 @@ <script lang="ts"> import { toast } from 'svelte-sonner'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; + import { v4 as uuidv4 } from 'uuid'; import { getModels as _getModels } from '$lib/apis'; const dispatch = createEventDispatcher(); @@ -12,16 +13,34 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; + import Cog6 from '$lib/components/icons/Cog6.svelte'; + import Cloud from '$lib/components/icons/Cloud.svelte'; import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte'; + import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import AddToolServerModal from '$lib/components/AddToolServerModal.svelte'; - import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs'; + import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + + import { + getToolServerConnections, + setToolServerConnections, + getTerminalServerConnections, + setTerminalServerConnections + } from '$lib/apis/configs'; export let saveSettings: Function; let servers = null; let showConnectionModal = false; + // Terminal server admin connections + let terminalConnections: { id: string; url: string; key: string; name: string }[] = []; + let showAddTerminalModal = false; + let editTerminalIdx: number | null = null; + let showDeleteTerminalConfirm = false; + let deleteTerminalIdx: number | null = null; + const addConnectionHandler = async (server) => { servers = [...servers, server]; await updateHandler(); @@ -32,7 +51,6 @@ TOOL_SERVER_CONNECTIONS: servers }).catch((err) => { toast.error($i18n.t('Failed to save connections')); - return null; }); @@ -41,14 +59,92 @@ } }; + const saveTerminalServers = async () => { + const res = await setTerminalServerConnections(localStorage.token, { + TERMINAL_SERVER_CONNECTIONS: terminalConnections + }).catch((err) => { + toast.error($i18n.t('Failed to save terminal servers')); + return null; + }); + + if (res) { + toast.success($i18n.t('Terminal servers saved')); + } + }; + + const addTerminalConnection = (server: { url: string; key: string; name?: string }) => { + terminalConnections = [ + ...terminalConnections, + { id: uuidv4(), url: server.url, key: server.key, name: server.name ?? '' } + ]; + saveTerminalServers(); + }; + + const updateTerminalConnection = ( + idx: number, + updated: { url: string; key: string; name?: string } + ) => { + terminalConnections = terminalConnections.map((c, i) => + i === idx ? { ...c, url: updated.url, key: updated.key, name: updated.name ?? '' } : c + ); + saveTerminalServers(); + }; + + const removeTerminalConnection = (idx: number) => { + terminalConnections = terminalConnections.filter((_, i) => i !== idx); + saveTerminalServers(); + }; + onMount(async () => { const res = await getToolServerConnections(localStorage.token); servers = res.TOOL_SERVER_CONNECTIONS; + + // Load terminal server connections + try { + const terminalRes = await getTerminalServerConnections(localStorage.token); + if (terminalRes?.TERMINAL_SERVER_CONNECTIONS) { + terminalConnections = terminalRes.TERMINAL_SERVER_CONNECTIONS; + } + } catch { + // Not configured yet + } }); </script> <AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} /> +<AddTerminalServerModal + admin + bind:show={showAddTerminalModal} + edit={editTerminalIdx !== null} + connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null} + onSubmit={(c) => { + if (editTerminalIdx !== null) { + updateTerminalConnection(editTerminalIdx, c); + editTerminalIdx = null; + } else { + addTerminalConnection(c); + } + }} + onDelete={() => { + if (editTerminalIdx !== null) { + deleteTerminalIdx = editTerminalIdx; + showDeleteTerminalConfirm = true; + editTerminalIdx = null; + } + }} +/> + +<ConfirmDialog + bind:show={showDeleteTerminalConfirm} + on:confirm={() => { + if (deleteTerminalIdx !== null) { + removeTerminalConnection(deleteTerminalIdx); + deleteTerminalIdx = null; + } + }} +/> + <form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={() => { @@ -64,9 +160,6 @@ <hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" /> <div class="mb-2.5 flex flex-col w-full justify-between"> - <!-- {$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { - URL: 'server?.url' - })} --> <div class="flex justify-between items-center mb-0.5"> <div class="font-medium">{$i18n.t('Manage Tool Servers')}</div> @@ -98,9 +191,98 @@ {/each} </div> - <div class="my-1.5"> + {#if servers.length === 0} + <div class="text-xs text-gray-400 dark:text-gray-500"> + {$i18n.t('No tool server connections configured.')} + </div> + {/if} + + <div class="my-1.5"> + <div class="text-xs text-gray-500"> + {$i18n.t('Connect to your own OpenAPI compatible external tool servers.')} + </div> + </div> + </div> + + <hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" /> + + <div class="mb-2.5 flex flex-col w-full"> + <div class="flex justify-between items-center mb-1"> + <div class="flex items-center gap-2"> + <div class="font-medium">{$i18n.t('Open Terminal')}</div> + <span + class="text-[0.65rem] font-medium uppercase px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400" + >{$i18n.t('Experimental')}</span + > + </div> + + <Tooltip content={$i18n.t('Add Connection')}> + <button + class="px-1" + on:click={() => { + editTerminalIdx = null; + showAddTerminalModal = true; + }} + type="button" + > + <Plus /> + </button> + </Tooltip> + </div> + + <div class="flex flex-col gap-1.5"> + {#each terminalConnections as connection, idx} + <div class="flex w-full gap-2 items-center"> + <Tooltip className="w-full relative" content={''} placement="top-start"> + <div class="flex w-full"> + <div class="flex-1 relative flex gap-1.5 items-center"> + <Tooltip content={$i18n.t('Terminal')}> + <Cloud className="size-4" strokeWidth="1.5" /> + </Tooltip> + + <div class="capitalize outline-hidden w-full bg-transparent text-sm"> + {connection.name || connection.url || $i18n.t('New Terminal')} + </div> + </div> + </div> + </Tooltip> + + <div class="flex gap-1 items-center"> + <Tooltip content={$i18n.t('Configure')}> + <button + class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition" + on:click={() => { + editTerminalIdx = idx; + showAddTerminalModal = true; + }} + type="button" + > + <Cog6 /> + </button> + </Tooltip> + </div> + </div> + {/each} + </div> + + {#if terminalConnections.length === 0} + <div class="text-xs text-gray-400 dark:text-gray-500"> + {$i18n.t('No terminal connections configured.')} + </div> + {/if} + + <div class="mt-1.5"> <div class="text-xs text-gray-500"> - {$i18n.t('Connect to your own OpenAPI compatible external tool servers.')} + {$i18n.t( + 'Connect to Open Terminal instances. All users will have access to file browsing and terminal tools through these servers.' + )} + </div> + <div class="text-xs text-gray-600 dark:text-gray-300 mt-1"> + <a + class="underline" + href="https://github.com/open-webui/open-terminal" + target="_blank">{$i18n.t('Learn more about Open Terminal')} ↗</a + > </div> </div> </div>
src/lib/components/chat/ChatControls.svelte+3 −1 modified@@ -10,6 +10,7 @@ import { onDestroy, onMount, tick, getContext } from 'svelte'; import { + terminalServers, mobile, showControls, showCallOverlay, @@ -58,7 +59,8 @@ // Tab state for Controls+Files panel let activeTab: 'controls' | 'files' | 'overview' = savedTab; $: savedTab = activeTab; - $: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url; + $: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url + || $terminalServers.length > 0; $: hasMessages = history?.messages && Object.keys(history.messages).length > 0; $: if (!hasMessages && activeTab === 'overview') activeTab = 'controls';
src/lib/components/chat/Chat.svelte+8 −18 modified@@ -45,7 +45,9 @@ showEmbeds } from '$lib/stores'; - import { getCwd } from '$lib/apis/terminal'; + import { WEBUI_API_BASE_URL } from '$lib/constants'; + + import { convertMessagesToHistory, copyToClipboard, @@ -2128,21 +2130,9 @@ }); } - // Build terminal servers with current CWD injected into run_command descriptions - const terminalServersWithCwd = await (async () => { - const terminals = JSON.parse(JSON.stringify($terminalServers ?? [])); - const configs = ($settings?.terminalServers ?? []).filter((s) => s.enabled); - await Promise.all( - configs.map(async (t) => { - const cwd = await getCwd(t.url, t.key ?? '').catch(() => null); - if (!cwd) return; - const server = terminals.find((s) => s.url === t.url); - const spec = server?.specs?.find((s) => s.name === 'run_command'); - if (spec) spec.description += `\n\nThe current working directory is: ${cwd}`; - }) - ); - return terminals; - })(); + // Determine the active terminal (first accessible backend terminal, or user-configured) + const activeTerminal = $terminalServers?.[0] ?? null; + const activeTerminalId = activeTerminal?.id ?? null; const res = await generateOpenAIChatCompletion( localStorage.token, @@ -2166,11 +2156,11 @@ filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, tool_ids: toolIds.length > 0 ? toolIds : undefined, skill_ids: skillIds.length > 0 ? skillIds : undefined, + terminal_id: activeTerminalId ?? undefined, tool_servers: [ ...($toolServers ?? []).filter( (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) - ), - ...terminalServersWithCwd + ) ], features: getFeatures(), variables: {
src/lib/components/chat/FileNav.svelte+6 −4 modified@@ -6,7 +6,8 @@ <script lang="ts"> import { toast } from 'svelte-sonner'; import { getContext, onMount, onDestroy, tick } from 'svelte'; - import { settings, showFileNavPath } from '$lib/stores'; + import { terminalServers, settings, showFileNavPath } from '$lib/stores'; + import { WEBUI_API_BASE_URL } from '$lib/constants'; import { getCwd, listFiles, @@ -58,9 +59,10 @@ let showDeleteConfirm = false; let shiftKey = false; - $: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled); - $: terminalUrl = activeTerminal?.url ?? ''; - $: terminalKey = activeTerminal?.key ?? ''; + $: firstTerminal = $terminalServers?.[0] ?? null; + $: activeUserTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled); + $: terminalUrl = firstTerminal?.url ?? activeUserTerminal?.url ?? ''; + $: terminalKey = firstTerminal?.key ?? activeUserTerminal?.key ?? ''; $: configured = !!terminalUrl; $: breadcrumbs = currentPath
src/routes/(app)/+layout.svelte+31 −12 modified@@ -12,9 +12,10 @@ import { getModels, getToolServersData, getVersionUpdates } from '$lib/apis'; import { getTools } from '$lib/apis/tools'; import { getBanners } from '$lib/apis/configs'; + import { getTerminalServers } from '$lib/apis/terminal'; import { getUserSettings } from '$lib/apis/users'; - import { WEBUI_VERSION } from '$lib/constants'; + import { WEBUI_VERSION, WEBUI_API_BASE_URL } from '$lib/constants'; import { compareVersion } from '$lib/utils'; import { @@ -144,22 +145,40 @@ config: { enable: true } })) ); - terminalServersData = terminalServersData.filter((data) => { - if (!data || data.error) { - toast.error( - $i18n.t(`Failed to connect to {{URL}} terminal server`, { - URL: data?.url - }) - ); - return false; - } - return true; - }); + terminalServersData = terminalServersData + .filter((data) => { + if (!data || data.error) { + toast.error( + $i18n.t(`Failed to connect to {{URL}} terminal server`, { + URL: data?.url + }) + ); + return false; + } + return true; + }) + .map((data, i) => ({ + ...data, + key: enabledTerminals[i]?.key ?? '' + })); terminalServers.set(terminalServersData); } else { terminalServers.set([]); } + + // Fetch terminal servers the user has access to (for FileNav + terminal_id) + const backendTerminals = await getTerminalServers(localStorage.token); + if (backendTerminals.length > 0) { + // Store with proxy URL and session key for FileNav file browsing + const terminalEntries = backendTerminals.map((t) => ({ + id: t.id, + url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`, + name: t.name, + key: localStorage.token + })); + terminalServers.update((existing) => [...existing, ...terminalEntries]); + } }; const setBanners = async () => {
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
4News mentions
0No linked articles in our index yet.