This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-08 15:47:10 -06:00
1 parent 8faaf2cd1e
commit 009999f363
12 files changed
+334 -32

No files matched your search

+36 -6
View File
@@ -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": "<jwt>"}`` 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
+90
View File
@@ -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
+22 -1
View File
@@ -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),