mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 18:55:07 +03:00
731 lines
31 KiB
Python
731 lines
31 KiB
Python
"""Shared routing helpers for admin-configured terminal servers."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import ntpath
|
|
import posixpath
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote
|
|
|
|
import aiohttp
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT_TERMINAL, ENABLE_TOOL_SERVERS
|
|
from open_webui.utils.headers import bearer_auth_header
|
|
from open_webui.utils.chat_id import is_saved_chat_id
|
|
from open_webui.utils.skill_files import (
|
|
SKILL_CONTENT_MAX_CHARS,
|
|
bounded_skill_manifest,
|
|
format_skill_content,
|
|
skill_content_page,
|
|
skill_overview,
|
|
)
|
|
|
|
TERMINAL_CHAT_UPLOAD_MODES = {'default', 'filesystem'}
|
|
MAX_AGENTS_MD_BYTES = 32 * 1024
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def is_terminal_orchestrator(connection: dict) -> bool:
|
|
return connection.get('server_type') == 'orchestrator'
|
|
|
|
|
|
def get_terminal_server_url(connection: dict) -> str:
|
|
return str(connection.get('url') or '').rstrip('/')
|
|
|
|
|
|
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 server_request(connection, path, *, method='GET', body=None, params=None):
|
|
if not connection.get('key'):
|
|
raise HTTPException(503, 'A Terminals server key is required')
|
|
url = connection['url'].rstrip('/') + '/' + path.lstrip('/')
|
|
try:
|
|
async with aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=180)) as session:
|
|
async with session.request(
|
|
method,
|
|
url,
|
|
headers=bearer_auth_header(connection['key']),
|
|
json=body,
|
|
params=params,
|
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
|
allow_redirects=False,
|
|
) as response:
|
|
if response.status >= 300:
|
|
try:
|
|
detail = (await response.json()).get('detail', 'Terminals request failed')
|
|
except Exception:
|
|
detail = 'Terminals request failed'
|
|
raise HTTPException(response.status if response.status >= 400 else 502, detail)
|
|
return await response.json()
|
|
except (aiohttp.ClientError, asyncio.TimeoutError):
|
|
raise HTTPException(503, 'Terminals is unavailable')
|
|
|
|
|
|
async def get_connection(user, connection_id):
|
|
from open_webui.models.config import Config
|
|
from open_webui.utils.access_control import has_connection_access
|
|
|
|
if not ENABLE_TOOL_SERVERS:
|
|
raise HTTPException(403, 'Tool servers are disabled')
|
|
connections = await Config.get('terminal_server.connections', []) or []
|
|
connection = next((c for c in connections if c.get('id') == connection_id), None)
|
|
if not connection or not connection.get('enabled', True) or connection.get('server_type') != 'orchestrator':
|
|
raise HTTPException(404, 'Managed terminal connection not found')
|
|
if not await has_connection_access(user, connection) and not await has_connection_write_access(user, connection):
|
|
raise HTTPException(403, 'Terminal connection access denied')
|
|
return connection
|
|
|
|
|
|
async def has_connection_write_access(user, connection, user_group_ids=None):
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.utils.access_control import has_access
|
|
|
|
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
|
|
|
if user.role == 'admin' and (
|
|
BYPASS_ADMIN_ACCESS_CONTROL or not (connection.get('config') or {}).get('access_grants')
|
|
):
|
|
return True
|
|
if user_group_ids is None:
|
|
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
|
return await has_access(user.id, 'write', (connection.get('config') or {}).get('access_grants', []), user_group_ids)
|
|
|
|
|
|
def template_terminal_id(connection_id, template_id):
|
|
return f'template:{connection_id}:{quote(template_id, safe="")}'
|
|
|
|
|
|
def split_template_id(terminal_id):
|
|
if not terminal_id.startswith('template:'):
|
|
raise HTTPException(400, 'Invalid template terminal ID')
|
|
connection_id, separator, encoded = terminal_id.removeprefix('template:').rpartition(':')
|
|
template_id = unquote(encoded)
|
|
if (
|
|
not separator
|
|
or not connection_id
|
|
or not template_id
|
|
or template_terminal_id(connection_id, template_id) != terminal_id
|
|
):
|
|
raise HTTPException(400, 'Invalid template terminal ID')
|
|
return connection_id, template_id
|
|
|
|
|
|
async def require_terminal_access(user, terminal_id):
|
|
from open_webui.models.access_grants import AccessGrants
|
|
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
|
|
|
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
|
return
|
|
if await AccessGrants.has_access(user.id, 'terminal', terminal_id, 'read'):
|
|
return
|
|
raise HTTPException(403, 'Terminal access denied')
|
|
|
|
|
|
async def template_connection(user, terminal_id, permission='read'):
|
|
connection_id, template_id = split_template_id(terminal_id)
|
|
connection = await get_connection(user, connection_id)
|
|
if not await has_connection_write_access(user, connection):
|
|
if permission == 'write':
|
|
raise HTTPException(403, 'Terminal connection write access required')
|
|
await require_terminal_access(user, terminal_id)
|
|
template = await server_request(connection, f'api/v1/terminals/templates/{quote(template_id, safe="")}')
|
|
return connection, template
|
|
|
|
|
|
async def list_template_connections(user, connections):
|
|
from open_webui.models.access_grants import AccessGrants
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.models.users import UserModel
|
|
from open_webui.utils.access_control import has_connection_access
|
|
|
|
if not isinstance(user, UserModel):
|
|
user = UserModel(**user)
|
|
group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
|
result = []
|
|
for connection in connections:
|
|
if connection.get('server_type') != 'orchestrator' or not connection.get('enabled', True):
|
|
continue
|
|
writable = await has_connection_write_access(user, connection, group_ids)
|
|
if not writable and not await has_connection_access(user, connection, group_ids):
|
|
continue
|
|
try:
|
|
templates = await server_request(connection, 'api/v1/terminals/templates')
|
|
except HTTPException as exc:
|
|
log.warning('Cannot list templates for connection %s: HTTP %s', connection['id'], exc.status_code)
|
|
continue
|
|
selectors = [template_terminal_id(connection['id'], template['id']) for template in templates]
|
|
readable = (
|
|
set(selectors)
|
|
if writable
|
|
else await AccessGrants.get_accessible_resource_ids(
|
|
user_id=user.id,
|
|
resource_type='terminal',
|
|
resource_ids=selectors,
|
|
permission='read',
|
|
user_group_ids=group_ids,
|
|
)
|
|
)
|
|
for template, selector in zip(templates, selectors):
|
|
if selector not in readable:
|
|
continue
|
|
data = template.get('data') or {}
|
|
result.append(
|
|
{
|
|
**connection,
|
|
'id': selector,
|
|
'name': data.get('name') or template['id'],
|
|
'template_id': template['id'],
|
|
'scope': data.get('scope'),
|
|
'auth_type': 'bearer',
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
class MissingTerminalContext(HTTPException):
|
|
def __init__(self, scope):
|
|
detail = 'This terminal requires a saved chat' if scope == 'chat' else f'This terminal requires a {scope}'
|
|
super().__init__(409, detail)
|
|
|
|
|
|
def get_terminal_context(template, user_id, metadata=None):
|
|
"""Validate a template's scope and return context for its environment."""
|
|
metadata = metadata or {}
|
|
scope = (template.get('data') or {}).get('scope')
|
|
if scope not in (None, 'user', 'chat', 'model'):
|
|
raise HTTPException(422, 'Invalid template scope')
|
|
chat_id = metadata.get('chat_id')
|
|
model_id = metadata.get('model_id') or metadata.get('model')
|
|
if scope == 'chat' and not is_saved_chat_id(chat_id):
|
|
raise MissingTerminalContext(scope)
|
|
if scope == 'model' and not model_id:
|
|
raise MissingTerminalContext(scope)
|
|
return {
|
|
'user_id': user_id,
|
|
'chat_id': chat_id if is_saved_chat_id(chat_id) else None,
|
|
'model_id': model_id if scope == 'model' else None,
|
|
}
|
|
|
|
|
|
async def require_model_access(user, model_id, terminal_id, request=None):
|
|
if not model_id:
|
|
raise MissingTerminalContext('model')
|
|
if model_id.startswith('agent:'):
|
|
parts = model_id.split(':', 3)
|
|
if len(parts) != 4 or unquote(parts[1]) != terminal_id or not all(parts[1:]):
|
|
raise HTTPException(403, 'Agent model does not belong to this terminal')
|
|
# Agent access follows its terminal. Execution still validates the live agent catalog.
|
|
return
|
|
from open_webui.models.models import Models
|
|
from open_webui.utils.models import check_model_access, get_all_models, get_filtered_models
|
|
|
|
if request is not None:
|
|
models = await get_all_models(request, user=user)
|
|
permitted = await get_filtered_models(models, user)
|
|
if not any(model['id'] == model_id for model in permitted):
|
|
raise HTTPException(403, 'Model access denied')
|
|
else:
|
|
row = await Models.get_model_by_id(model_id)
|
|
if row is None:
|
|
raise HTTPException(403, 'Model not found')
|
|
try:
|
|
await check_model_access(user, {'id': model_id}, row)
|
|
except Exception as exc:
|
|
raise HTTPException(403, 'Model access denied') from exc
|
|
|
|
|
|
def terminal_headers(connection):
|
|
"""Only consumes context produced by find_terminal_connection, never browser headers."""
|
|
if not connection.get('template_id'):
|
|
return {'X-User-Id': connection['instance_user_id']} if connection.get('instance_user_id') else {}
|
|
context = connection['terminal_context']
|
|
headers = {
|
|
'X-Terminal-Template-Id': connection['template_id'],
|
|
'X-User-Id': context['user_id'],
|
|
}
|
|
if context.get('chat_id'):
|
|
headers['X-Terminal-Chat-Id'] = context['chat_id']
|
|
if context.get('model_id'):
|
|
headers['X-Terminal-Model-Id'] = context['model_id']
|
|
return headers
|
|
|
|
|
|
async def find_terminal_connection(user, terminal_id, metadata=None, request=None):
|
|
from open_webui.models.chats import Chats
|
|
from open_webui.models.users import UserModel, Users
|
|
from open_webui.utils.access_control import has_connection_access
|
|
from open_webui.utils.tether import get_terminal_connections
|
|
|
|
if not ENABLE_TOOL_SERVERS:
|
|
raise HTTPException(403, 'Tool servers are disabled')
|
|
if not isinstance(user, UserModel):
|
|
user = UserModel(**user)
|
|
metadata = dict(metadata or {})
|
|
chat = None
|
|
shared_session = None
|
|
instance_user = user
|
|
chat_id = metadata.get('chat_id')
|
|
if chat_id and is_saved_chat_id(chat_id):
|
|
chat = await Chats.get_accessible_chat_by_id(chat_id, user, permission='write')
|
|
if chat is None:
|
|
raise HTTPException(403, 'Chat access denied')
|
|
if chat.meta.get('internal') is True and chat.meta.get('type') == 'subagent':
|
|
# A sub-agent shares the machine and files of its parent chat.
|
|
metadata['chat_id'] = chat.meta.get('parent_chat_id')
|
|
return await find_terminal_connection(user, terminal_id, metadata, request=request)
|
|
from open_webui.utils.agents.session import get_session, model_id
|
|
|
|
shared_session = await get_session(chat)
|
|
if shared_session and shared_session['terminal_id'] != terminal_id:
|
|
shared_session = None
|
|
if shared_session:
|
|
metadata['model_id'] = model_id(shared_session)
|
|
instance_user_id = shared_session.get('terminal_user_id') or shared_session['user']
|
|
instance_user = user if instance_user_id == user.id else await Users.get_user_by_id(instance_user_id)
|
|
if instance_user is None:
|
|
raise HTTPException(403, 'Agent terminal owner is unavailable')
|
|
|
|
def authorized_connection(connection):
|
|
if shared_session:
|
|
connection = {**connection, 'instance_user_id': instance_user.id}
|
|
return connection
|
|
|
|
if not terminal_id.startswith('template:'):
|
|
connections = await get_terminal_connections(instance_user, include_templates=False)
|
|
connection = next((c for c in connections if c.get('id') == terminal_id), None)
|
|
if (
|
|
not connection
|
|
or not connection.get('enabled', True)
|
|
or not await has_connection_access(instance_user, connection)
|
|
):
|
|
return None
|
|
if is_terminal_orchestrator(connection):
|
|
raise HTTPException(409, 'Select a terminal template')
|
|
return authorized_connection(connection)
|
|
|
|
connection_id, template_id = split_template_id(terminal_id)
|
|
connection = await get_connection(instance_user, connection_id)
|
|
template = await server_request(connection, f'api/v1/terminals/templates/{quote(template_id, safe="")}')
|
|
scope = (template.get('data') or {}).get('scope')
|
|
|
|
if chat and not shared_session:
|
|
stored = (chat.chat or {}).get('terminal_id')
|
|
if chat.user_id != user.id and stored != terminal_id:
|
|
raise HTTPException(403, 'Use the terminal selected by the chat owner')
|
|
if stored and stored != terminal_id:
|
|
raise HTTPException(409, 'Chat terminal selection changed; refresh the chat')
|
|
|
|
principal = instance_user
|
|
if not shared_session and scope == 'chat' and chat:
|
|
owner = user if chat.user_id == user.id else await Users.get_user_by_id(chat.user_id)
|
|
if owner is None:
|
|
raise HTTPException(403, 'Chat owner is unavailable')
|
|
if (
|
|
owner.id != instance_user.id
|
|
and not await has_connection_access(owner, connection)
|
|
and not await has_connection_write_access(owner, connection)
|
|
):
|
|
raise HTTPException(403, 'Terminal connection access denied')
|
|
principal = owner
|
|
if not await has_connection_write_access(principal, connection):
|
|
await require_terminal_access(principal, terminal_id)
|
|
|
|
context = get_terminal_context(template, instance_user.id, metadata)
|
|
if scope == 'model':
|
|
await require_model_access(instance_user, context['model_id'], terminal_id, request)
|
|
if metadata.get('automation_id'):
|
|
from open_webui.models.automations import Automations
|
|
|
|
automation = await Automations.get_by_id(metadata['automation_id'])
|
|
if not automation or automation.user_id != user.id:
|
|
raise HTTPException(403, 'Automation terminal access denied')
|
|
return authorized_connection(
|
|
{
|
|
**connection,
|
|
'id': terminal_id,
|
|
'template_id': template_id,
|
|
'scope': scope,
|
|
'auth_type': 'bearer',
|
|
'terminal_context': context,
|
|
}
|
|
)
|
|
|
|
|
|
async def resolve_terminal_connection(request, user, metadata):
|
|
"""Resolve an authorized backend connection and trusted instance headers."""
|
|
terminal_id = metadata.get('terminal_id')
|
|
if not ENABLE_TOOL_SERVERS or not terminal_id:
|
|
return None
|
|
from open_webui.models.users import UserModel
|
|
from open_webui.utils.tools import build_tool_server_headers
|
|
|
|
user_model = user if isinstance(user, UserModel) else UserModel(**user)
|
|
connection = await find_terminal_connection(user_model, terminal_id, metadata, request=request)
|
|
|
|
if connection:
|
|
headers, cookies = await build_tool_server_headers(
|
|
connection,
|
|
request,
|
|
user_model,
|
|
metadata=metadata,
|
|
)
|
|
headers.update(terminal_headers(connection))
|
|
headers['Accept'] = 'application/json'
|
|
headers['X-User-Id'] = connection.get('instance_user_id', user_model.id)
|
|
if metadata.get('chat_id'):
|
|
headers['X-Session-Id'] = metadata['chat_id']
|
|
return connection, headers, cookies
|
|
return None
|
|
|
|
|
|
async def get_terminal_json(
|
|
request,
|
|
user,
|
|
metadata: dict,
|
|
path: str,
|
|
extra_params: dict | None = None,
|
|
*,
|
|
file: dict | None = None,
|
|
method: str = 'GET',
|
|
body: dict | None = None,
|
|
):
|
|
"""Read JSON or upload a file through the chat's terminal connection."""
|
|
metadata = metadata or {}
|
|
terminal_id = metadata.get('terminal_id')
|
|
if not ENABLE_TOOL_SERVERS or not terminal_id:
|
|
return None
|
|
|
|
import aiohttp
|
|
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
|
|
from open_webui.utils.tether import get_terminal_client
|
|
|
|
resolved = await resolve_terminal_connection(request, user, metadata)
|
|
if resolved:
|
|
connection, headers, cookies = resolved
|
|
json_body = body
|
|
body = None
|
|
if file:
|
|
from open_webui.models.files import Files
|
|
from open_webui.storage.provider import Storage
|
|
|
|
source = await Files.get_file_by_id(file['id'])
|
|
if not source or source.user_id != (user.id if hasattr(user, 'id') else user['id']) or not source.path:
|
|
return None
|
|
local_path = await asyncio.to_thread(Storage.get_file, source.path)
|
|
body = aiohttp.FormData()
|
|
body.add_field(
|
|
'file',
|
|
open(local_path, 'rb'),
|
|
filename=file['name'],
|
|
content_type=(source.meta or {}).get('content_type') or 'application/octet-stream',
|
|
)
|
|
headers = {key: value for key, value in headers.items() if key.lower() != 'content-type'}
|
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TERMINAL)
|
|
try:
|
|
async with get_terminal_client(
|
|
get_terminal_server_url(connection),
|
|
connection.get('instance_user_id') or (user.id if hasattr(user, 'id') else user['id']),
|
|
service=connection.get('service', 'open_terminal'),
|
|
timeout=timeout,
|
|
trust_env=True,
|
|
) as session:
|
|
async with session.request(
|
|
'POST' if file else method,
|
|
f'{get_terminal_server_url(connection)}{path}',
|
|
headers=headers,
|
|
cookies=cookies,
|
|
data=body,
|
|
json=None if file else json_body,
|
|
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
|
allow_redirects=False,
|
|
) as response:
|
|
return await response.json() if 200 <= response.status < 300 else None
|
|
except TimeoutError as error:
|
|
raise TimeoutError(
|
|
f"Terminal '{connection.get('name') or terminal_id}' timed out. "
|
|
'It may still be starting. Please try again.'
|
|
) from error
|
|
return None
|
|
|
|
|
|
async def get_folder_terminal_defaults(user, folder_id):
|
|
if not folder_id:
|
|
return {}
|
|
from fastapi import HTTPException
|
|
from open_webui.models.folders import Folders
|
|
from open_webui.utils.access_control.folders import has_folder_access
|
|
|
|
folder = await Folders.get_folder_by_id(folder_id)
|
|
if not folder or not await has_folder_access(user.id, folder, 'write', None):
|
|
raise HTTPException(403, 'Folder is unavailable')
|
|
data = folder.data or {}
|
|
return {
|
|
'terminal_id': data.get('terminal_id'),
|
|
'working_directory': data.get('working_directory'),
|
|
}
|
|
|
|
|
|
async def initialize_terminal_directory(request, user, metadata, terminal_id, path):
|
|
if not terminal_id or not path:
|
|
return
|
|
from fastapi import HTTPException
|
|
from open_webui.utils.tether import get_terminal_connections
|
|
|
|
if not isinstance(path, str):
|
|
raise HTTPException(400, 'Invalid working directory')
|
|
# A terminal only the browser reaches gets its folder from the browser.
|
|
if not any(connection.get('id') == terminal_id for connection in await get_terminal_connections(user)):
|
|
return
|
|
result = await get_terminal_json(
|
|
request,
|
|
user,
|
|
{**metadata, 'terminal_id': terminal_id},
|
|
'/files/cwd',
|
|
method='POST',
|
|
body={'path': path},
|
|
)
|
|
if not result or not result.get('cwd'):
|
|
raise HTTPException(400, 'Working directory is unavailable. Check the terminal connection and path.')
|
|
metadata['initial_terminal_cwd'] = result['cwd']
|
|
|
|
|
|
async def filter_user_terminal_tools(request, user, tools: dict, metadata: dict, extra_params: dict):
|
|
"""Let Open Terminal, which owns the shells, confirm availability for this user and chat."""
|
|
terminal_id = metadata.get('terminal_id')
|
|
shell_tools = {
|
|
name: tool
|
|
for name, tool in tools.items()
|
|
if name in {'read_user_terminal', 'send_user_terminal_input'} and tool.get('type') == 'terminal'
|
|
}
|
|
selected = {
|
|
name: tool
|
|
for name, tool in shell_tools.items()
|
|
if terminal_id and tool.get('tool_id') == f'terminal:{terminal_id}'
|
|
}
|
|
available = False
|
|
if selected and not metadata.get('automation_id') and not metadata.get('internal'):
|
|
available = any(tool.get('user_shell_tools') == 'always' for tool in selected.values())
|
|
if not available and metadata.get('chat_id'):
|
|
try:
|
|
# This endpoint checks ownership, a live process, and a connected shell.
|
|
# Its output is discarded; the model must explicitly read the terminal.
|
|
async with asyncio.timeout(5):
|
|
state = await get_terminal_json(request, user, metadata, '/api/terminals/user/output', extra_params)
|
|
available = isinstance(state, dict) and isinstance(state.get('output'), str)
|
|
except Exception as error:
|
|
log.debug('User terminal availability check failed (%s)', type(error).__name__)
|
|
|
|
for name in shell_tools:
|
|
if not available or name not in selected:
|
|
tools.pop(name)
|
|
|
|
if available and 'read_user_terminal' in selected:
|
|
tool = selected['read_user_terminal']
|
|
tools['read_user_terminal'] = {
|
|
**tool,
|
|
'spec': {
|
|
**tool['spec'],
|
|
'description': tool['spec'].get('description', '')
|
|
+ "\nReads the user's Open WebUI Terminal tab, separate from the Desktop. "
|
|
'Use this tool when the user refers to an open terminal tab.',
|
|
},
|
|
}
|
|
|
|
|
|
async def upload_files_to_terminal(request, user, files: list[dict], metadata: dict, extra_params: dict):
|
|
"""Copy Open WebUI files to the selected terminal, adding terminal_path or terminal_error to each entry."""
|
|
if not metadata.get('terminal_id') or not files:
|
|
return
|
|
try:
|
|
cwd = await asyncio.wait_for(
|
|
get_terminal_json(request, user, metadata, '/files/cwd', extra_params),
|
|
timeout=AIOHTTP_CLIENT_TIMEOUT_TERMINAL,
|
|
)
|
|
directory = cwd.get('cwd') if isinstance(cwd, dict) else None
|
|
if not directory:
|
|
raise ValueError('Terminal working directory unavailable')
|
|
path = f'/files/upload?directory={quote(directory, safe="")}'
|
|
for entry in files:
|
|
try:
|
|
# The file ID keeps separate files from overwriting one another.
|
|
file = {'id': entry['id'], 'name': f'{entry["id"]}{Path(entry["name"]).suffix}'}
|
|
result = await asyncio.wait_for(
|
|
get_terminal_json(request, user, metadata, path, extra_params, file=file),
|
|
timeout=AIOHTTP_CLIENT_TIMEOUT_TERMINAL,
|
|
)
|
|
if not isinstance(result, dict) or not isinstance(result.get('path'), str) or not result['path']:
|
|
raise ValueError('Terminal upload failed')
|
|
entry['terminal_path'] = result['path']
|
|
except Exception as error:
|
|
log.warning('File terminal upload failed (%s)', type(error).__name__)
|
|
entry['terminal_error'] = 'File saved in Open WebUI, but upload to the selected terminal failed.'
|
|
except Exception as error:
|
|
log.warning('File terminal unavailable (%s)', type(error).__name__)
|
|
for entry in files:
|
|
entry['terminal_error'] = 'File saved in Open WebUI, but the selected terminal is unavailable.'
|
|
|
|
|
|
async def get_terminal_file(request, user, metadata: dict, path: str, *, max_bytes: int) -> bytes:
|
|
"""Read a bounded file through the authorized terminal connection."""
|
|
import aiohttp
|
|
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
|
|
from open_webui.utils.tether import get_terminal_client
|
|
|
|
resolved = await resolve_terminal_connection(request, user, metadata)
|
|
if not resolved:
|
|
raise ValueError('Reading files requires a terminal connection reachable by Open WebUI.')
|
|
connection, headers, cookies = resolved
|
|
base_url = get_terminal_server_url(connection)
|
|
async with get_terminal_client(
|
|
base_url,
|
|
connection.get('instance_user_id') or (user.id if hasattr(user, 'id') else user['id']),
|
|
service=connection.get('service', 'open_terminal'),
|
|
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TERMINAL),
|
|
trust_env=True,
|
|
) as session:
|
|
async with session.get(
|
|
f'{base_url}/files/view?path={quote(path, safe="")}',
|
|
headers=headers,
|
|
cookies=cookies,
|
|
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
|
allow_redirects=False,
|
|
) as response:
|
|
if response.status != 200:
|
|
raise ValueError(f'Could not read {path} from the terminal.')
|
|
data = bytearray()
|
|
async for chunk in response.content.iter_chunked(64 * 1024):
|
|
if len(data) + len(chunk) > max_bytes:
|
|
raise ValueError(f'File exceeds the {max_bytes}-byte limit.')
|
|
data.extend(chunk)
|
|
return bytes(data)
|
|
|
|
|
|
async def get_terminal_agents_md(request, user, metadata: dict, extra_params: dict | None = None) -> str | None:
|
|
"""Load the selected terminal user's home AGENTS.md afresh for this turn."""
|
|
try:
|
|
async with asyncio.timeout(5):
|
|
data = await get_terminal_json(request, user, metadata, '/files/cwd', extra_params)
|
|
home = data.get('home') if isinstance(data, dict) else None
|
|
if not isinstance(home, str) or not (posixpath.isabs(home) or ntpath.isabs(home)):
|
|
return None
|
|
path = quote(posixpath.join(home, 'AGENTS.md'), safe='')
|
|
data = await get_terminal_json(request, user, metadata, f'/files/read?path={path}', extra_params)
|
|
|
|
content = data.get('content') if isinstance(data, dict) else None
|
|
if not isinstance(content, str) or not content.strip():
|
|
return None
|
|
if len(content.encode('utf-8')) > MAX_AGENTS_MD_BYTES:
|
|
log.warning('Skipping terminal AGENTS.md: exceeds %s bytes', MAX_AGENTS_MD_BYTES)
|
|
return None
|
|
return f'# AGENTS.md\n\n{content}'
|
|
except Exception as e:
|
|
log.debug('Failed to load terminal AGENTS.md (%s)', type(e).__name__)
|
|
return None
|
|
|
|
|
|
def add_terminal_agents_md(messages: list[dict], agents_md: str) -> list[dict]:
|
|
"""Place file instructions before user requests without changing their content."""
|
|
for index, message in enumerate(messages):
|
|
if message.get('role') == 'user':
|
|
return [*messages[:index], {'role': 'user', 'content': agents_md}, *messages[index:]]
|
|
return messages
|
|
|
|
|
|
async def load_terminal_skill(
|
|
request,
|
|
user,
|
|
metadata: dict,
|
|
skill_name: str,
|
|
extra_params: dict | None = None,
|
|
*,
|
|
refresh: bool = True,
|
|
) -> dict | None:
|
|
"""Load the full source internally and pin/check its fingerprint before shaping model output."""
|
|
skill = await get_terminal_json(
|
|
request, user, metadata, f'/skills/read?name={quote(skill_name, safe="")}', extra_params
|
|
)
|
|
|
|
if not isinstance(skill, dict) or not isinstance(skill.get('content'), str):
|
|
return None
|
|
|
|
context = metadata.get('chat_context') or {}
|
|
metadata['chat_context'] = context
|
|
fingerprints = context.setdefault('terminal_skill_fingerprints', {}).setdefault(metadata['terminal_id'], {})
|
|
skill_id = f'terminal:{quote(skill_name, safe="")}'
|
|
fingerprint = hashlib.sha256(skill['content'].encode('utf-8')).hexdigest()
|
|
previous = fingerprints.get(skill_id)
|
|
if not refresh and previous is not None and previous != fingerprint:
|
|
raise ValueError('Skill changed while reading. Call view_skill again and restart reading.')
|
|
fingerprints[skill_id] = fingerprint
|
|
return skill
|
|
|
|
|
|
async def get_terminal_skill(
|
|
request,
|
|
user,
|
|
metadata: dict,
|
|
skill_name: str,
|
|
extra_params: dict | None = None,
|
|
*,
|
|
offset: int = 0,
|
|
max_chars: int = SKILL_CONTENT_MAX_CHARS,
|
|
refresh: bool = True,
|
|
overview: bool = False,
|
|
tools_enabled: bool = True,
|
|
) -> dict | None:
|
|
skill = await load_terminal_skill(request, user, metadata, skill_name, extra_params, refresh=refresh)
|
|
if skill is None:
|
|
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'),
|
|
'directory': directory,
|
|
**(
|
|
await skill_overview(skill['content'], resources, 'resources', tools_enabled)
|
|
if overview
|
|
else {
|
|
**await skill_content_page(skill['content'], offset, max_chars),
|
|
**bounded_skill_manifest(resources, 'resources'),
|
|
}
|
|
),
|
|
}
|
|
|
|
|
|
def format_terminal_skill_context(skill: dict, skill_id: str, tools_enabled: bool) -> str:
|
|
resources = skill.get('resources') if isinstance(skill.get('resources'), list) else []
|
|
parts = [f'<skill name="{skill.get("name") or ""}">', format_skill_content(skill, skill_id, tools_enabled)]
|
|
if skill.get('directory'):
|
|
parts.append(f'<directory>{skill["directory"]}</directory>')
|
|
if resources:
|
|
parts.append('<resources>')
|
|
parts.extend(f'<file>{resource}</file>' for resource in resources)
|
|
parts.append('</resources>')
|
|
if skill.get('notice'):
|
|
parts.append(skill['notice'])
|
|
parts.append('</skill>')
|
|
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>{location}</location>\n' if location else ''
|
|
return (
|
|
f'<skill>\n<id>{skill["id"]}</id>\n<name>{skill["name"]}</name>\n'
|
|
f'<description>{skill.get("description") or ""}</description>\n'
|
|
f'<source>terminal</source>\n'
|
|
f'{location_tag}'
|
|
f'</skill>\n'
|
|
)
|