"""Shared routing helpers for admin-configured terminal servers.""" 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'} TERMINAL_CHAT_UPLOAD_MODES = {'default', 'filesystem'} 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. An explicit policy uses the named-policy route. Connections without one keep their existing root route. """ base_url = str(connection.get('url') or '').rstrip('/') policy_id = str(connection.get('policy_id') or '').strip() 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 def terminal_chat_uploads(connection: dict) -> str: """Return normalized main-chat upload behavior for this connection.""" value = (connection.get('config') or {}).get('chat_uploads') return value if value in TERMINAL_CHAT_UPLOAD_MODES else 'default' async def get_terminal_request_info(request, user, metadata: dict, extra_params: dict | None = None): from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.users import UserModel from open_webui.utils.access_control import has_connection_access from open_webui.utils.headers import bearer_auth_header from open_webui.utils.tools import build_tool_server_headers metadata = metadata or {} terminal_id = metadata.get('terminal_id') if not terminal_id: return None user_model = user if isinstance(user, UserModel) else UserModel(**user) connections = await Config.get('terminal_server.connections', []) or [] connection = next((item for item in connections if item.get('id') == terminal_id), None) if connection: if not connection.get('enabled', True): return None user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_model.id)} if not await has_connection_access(user_model, connection, user_group_ids): return None headers, cookies = await build_tool_server_headers( connection, request, user_model, metadata=metadata, extra_params=extra_params, ) headers['Accept'] = 'application/json' headers['X-User-Id'] = user_model.id if metadata.get('chat_id'): headers['X-Session-Id'] = metadata['chat_id'] terminal_context = 'automation' if metadata.get('automation_id') else 'chat' context_id = terminal_context_id(connection, metadata, terminal_context) if context_id: headers[TERMINAL_CONTEXT_HEADER] = context_id return get_terminal_server_url(connection), headers, cookies selector = str(terminal_id).rstrip('/') direct_terminal = next( (server for server in metadata.get('tool_servers') or [] if str(server.get('url') or '').rstrip('/') == selector), None, ) if not direct_terminal: return None headers = {'Accept': 'application/json'} key = str(direct_terminal.get('key') or '').strip() if key: headers.update(bearer_auth_header(key)) if metadata.get('chat_id'): headers['X-Session-Id'] = metadata['chat_id'] return selector, headers, {} async def get_terminal_skill(request, user, metadata: dict, skill_name: str, extra_params: dict | None = None) -> dict | None: import aiohttp from urllib.parse import quote from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA terminal_request = await get_terminal_request_info(request, user, metadata, extra_params) if not terminal_request: return None base_url, headers, cookies = terminal_request timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( f'{base_url.rstrip("/")}/skills/{quote(skill_name, safe="")}', headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ) as response: skill = await response.json() if response.status == 200 else None if not isinstance(skill, dict): return None location = skill.get('location') or skill.get('path') or '' directory = location.rsplit('/', 1)[0] if '/' in location else location resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] return { 'name': skill.get('name'), 'description': skill.get('description'), 'content': skill.get('content'), 'directory': directory, 'resources': resources, } def format_terminal_skill_context(skill: dict) -> str: resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] parts = [f'', skill.get('content') or ''] if skill.get('directory'): parts.append(f'{skill["directory"]}') if resources: parts.append('') parts.extend(f'{resource}' for resource in resources) parts.append('') parts.append('') return '\n'.join(parts) def format_terminal_skill_manifest_entry(skill: dict) -> str: location = skill.get('location') or skill.get('path') or '' location_tag = f'{location}\n' if location else '' return ( f'\n{skill["id"]}\n{skill["name"]}\n' f'{skill.get("description") or ""}\n' f'terminal\n' f'{location_tag}' f'\n' )