diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py
index 9a16fd4ba8..57b2bd564a 100644
--- a/backend/open_webui/env.py
+++ b/backend/open_webui/env.py
@@ -806,6 +806,36 @@ else:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
+####################################
+# AIOHTTP Connection Pool
+####################################
+
+AIOHTTP_POOL_CONNECTIONS = os.environ.get('AIOHTTP_POOL_CONNECTIONS', '')
+if AIOHTTP_POOL_CONNECTIONS == '':
+ AIOHTTP_POOL_CONNECTIONS = None
+else:
+ try:
+ AIOHTTP_POOL_CONNECTIONS = int(AIOHTTP_POOL_CONNECTIONS)
+ except ValueError:
+ AIOHTTP_POOL_CONNECTIONS = None
+
+AIOHTTP_POOL_CONNECTIONS_PER_HOST = os.environ.get('AIOHTTP_POOL_CONNECTIONS_PER_HOST', '')
+if AIOHTTP_POOL_CONNECTIONS_PER_HOST == '':
+ AIOHTTP_POOL_CONNECTIONS_PER_HOST = None
+else:
+ try:
+ AIOHTTP_POOL_CONNECTIONS_PER_HOST = int(AIOHTTP_POOL_CONNECTIONS_PER_HOST)
+ except ValueError:
+ AIOHTTP_POOL_CONNECTIONS_PER_HOST = None
+
+AIOHTTP_POOL_DNS_TTL = os.environ.get('AIOHTTP_POOL_DNS_TTL', '300')
+try:
+ AIOHTTP_POOL_DNS_TTL = int(AIOHTTP_POOL_DNS_TTL)
+ if AIOHTTP_POOL_DNS_TTL < 0:
+ AIOHTTP_POOL_DNS_TTL = 300
+except ValueError:
+ AIOHTTP_POOL_DNS_TTL = 300
+
RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '')
if RAG_EMBEDDING_TIMEOUT == '':
@@ -909,6 +939,9 @@ AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
+# When enabled, GET requests are also audited (disabled by default to avoid log noise)
+ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'
+
####################################
# OPENTELEMETRY
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index d959351d4c..716d0c92a5 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -67,6 +67,7 @@ from open_webui.socket.main import (
periodic_session_pool_cleanup,
get_event_emitter,
get_models_in_use,
+ get_user_id_from_session_pool,
)
from open_webui.routers import (
analytics,
@@ -475,6 +476,7 @@ from open_webui.env import (
LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_INCLUDED_PATHS,
+ ENABLE_AUDIT_GET_REQUESTS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
@@ -565,6 +567,7 @@ from open_webui.tasks import (
list_task_ids_by_item_id,
create_task,
stop_task,
+ stop_item_tasks,
list_tasks,
) # Import from tasks.py
@@ -718,6 +721,10 @@ async def lifespan(app: FastAPI):
yield
+ # Shutdown: clean up shared resources
+ from open_webui.utils.session_pool import close_session
+ await close_session()
+
if hasattr(app.state, 'redis_task_command_listener'):
app.state.redis_task_command_listener.cancel()
@@ -1390,50 +1397,6 @@ app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
-class APIKeyRestrictionMiddleware:
- def __init__(self, app):
- self.app = app
-
- async def __call__(self, scope, receive, send):
- if scope['type'] == 'http':
- request = Request(scope)
- auth_header = request.headers.get('Authorization')
- token = None
-
- if auth_header:
- parts = auth_header.split(' ', 1)
- if len(parts) == 2:
- token = parts[1]
-
- # Only apply restrictions if an sk- API key is used
- if token and token.startswith('sk-'):
- # Check if restrictions are enabled
- if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
- allowed_paths = [
- path.strip()
- for path in str(app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',')
- if path.strip()
- ]
-
- request_path = request.url.path
-
- # Match exact path or prefix path
- is_allowed = any(
- request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths
- )
-
- if not is_allowed:
- await JSONResponse(
- status_code=status.HTTP_403_FORBIDDEN,
- content={'detail': 'API key not allowed to access this endpoint.'},
- )(scope, receive, send)
- return
-
- await self.app(scope, receive, send)
-
-
-app.add_middleware(APIKeyRestrictionMiddleware)
-
@app.middleware('http')
async def commit_session_after_request(request: Request, call_next):
@@ -1560,6 +1523,7 @@ if audit_level != AuditLevel.NONE:
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
+ audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
##################################
@@ -2012,7 +1976,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user=De
@app.post('/api/tasks/stop/{task_id}')
-async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_verified_user)):
+async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_admin_user)):
try:
result = await stop_task(request.app.state.redis, task_id)
return result
@@ -2021,15 +1985,21 @@ async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_ve
@app.get('/api/tasks')
-async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
+async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
return {'tasks': await list_tasks(request.app.state.redis)}
-@app.get('/api/tasks/chat/{chat_id}')
+@app.get('/api/tasks/chat/{chat_id:path}')
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
- chat = await Chats.get_chat_by_id(chat_id)
- if chat is None or chat.user_id != user.id:
- return {'task_ids': []}
+ if chat_id.startswith('local:'):
+ socket_id = chat_id[len('local:'):]
+ owner_id = get_user_id_from_session_pool(socket_id)
+ if owner_id != user.id and user.role != 'admin':
+ return {'task_ids': []}
+ else:
+ chat = await Chats.get_chat_by_id(chat_id)
+ if chat is None or (chat.user_id != user.id and user.role != 'admin'):
+ return {'task_ids': []}
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
@@ -2037,6 +2007,21 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
return {'task_ids': task_ids}
+@app.post('/api/tasks/chat/{chat_id:path}/stop')
+async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
+ if chat_id.startswith('local:'):
+ socket_id = chat_id[len('local:'):]
+ owner_id = get_user_id_from_session_pool(socket_id)
+ if owner_id != user.id and user.role != 'admin':
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
+ else:
+ chat = await Chats.get_chat_by_id(chat_id)
+ if chat is None or (chat.user_id != user.id and user.role != 'admin'):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
+ result = await stop_item_tasks(request.app.state.redis, chat_id)
+ return result
+
+
##################################
#
# Config Endpoints
diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py
index c9442f208b..cc520ffe63 100644
--- a/backend/open_webui/retrieval/web/utils.py
+++ b/backend/open_webui/retrieval/web/utils.py
@@ -1,4 +1,5 @@
import asyncio
+import ipaddress
import logging
import socket
import ssl
@@ -84,11 +85,9 @@ def validate_url(url: Union[str, Sequence[str]]):
ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
# Check if any of the resolved addresses are private
# This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
- for ip in ipv4_addresses:
- if validators.ipv4(ip, private=True):
- raise ValueError(ERROR_MESSAGES.INVALID_URL)
- for ip in ipv6_addresses:
- if validators.ipv6(ip, private=True):
+ for ip in ipv4_addresses + ipv6_addresses:
+ addr = ipaddress.ip_address(ip)
+ if not addr.is_global:
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return True
elif isinstance(url, Sequence):
diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py
index 484212a493..6652ebf44d 100644
--- a/backend/open_webui/routers/auths.py
+++ b/backend/open_webui/routers/auths.py
@@ -54,6 +54,7 @@ from open_webui.config import (
OAUTH_PROVIDERS,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
)
+from open_webui.utils.oauth import auth_manager_config
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
@@ -322,6 +323,14 @@ async def ldap_auth(
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
+ # Reject empty passwords before attempting the LDAP bind.
+ # Per RFC 4513 §5.1.2, a Simple Bind with a non-empty DN but empty
+ # password is "unauthenticated simple authentication" — many LDAP
+ # servers (OpenLDAP default, some AD configs) return success for these,
+ # which would grant access without valid credentials.
+ if not form_data.password or not form_data.password.strip():
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
# NOW load LDAP config variables
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
@@ -1295,6 +1304,17 @@ async def token_exchange(
)
email = email.lower()
+ # Enforce domain allowlist — same check as the normal OAuth callback
+ if (
+ '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+ and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+ ):
+ log.warning(f'Token exchange denied: email domain not in allowed domains list')
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+ )
+
# Try to find the user by OAuth sub
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py
index 504bc726d2..9c532a8915 100644
--- a/backend/open_webui/routers/automations.py
+++ b/backend/open_webui/routers/automations.py
@@ -167,15 +167,6 @@ async def create_new_automation(
await check_automation_limits(request, user, form_data.data.rrule, db, is_create=True)
- # Validate terminal server exists if linked
- if form_data.data.terminal and form_data.data.terminal.server_id:
- connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
- if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail='Terminal server not found',
- )
-
tz = user.timezone
automation = await Automations.insert(user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return await enrich_automation(automation, db, tz=tz)
@@ -226,15 +217,6 @@ async def update_automation_by_id(
await check_automation_limits(request, user, form_data.data.rrule, db, is_create=False)
- # Validate terminal server exists if linked
- if form_data.data.terminal and form_data.data.terminal.server_id:
- connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
- if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail='Terminal server not found',
- )
-
tz = user.timezone
updated = await Automations.update_by_id(id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return await enrich_automation(updated, db, tz=tz)
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index 6610ee2eca..5c2ab9dcac 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -61,7 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user
-from open_webui.utils.access_control import has_permission
+from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
from open_webui.internal.db import get_async_session
@@ -303,6 +303,14 @@ async def create_new_channel(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
+ form_data.access_grants = filter_allowed_access_grants(
+ request.app.state.config.USER_PERMISSIONS,
+ user.id,
+ user.role,
+ form_data.access_grants,
+ 'sharing.public_channels',
+ )
+
try:
if form_data.type == 'dm':
existing_channel = await Channels.get_dm_channel_by_user_ids([user.id, *form_data.user_ids], db=db)
@@ -633,6 +641,14 @@ async def update_channel_by_id(
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
+ form_data.access_grants = filter_allowed_access_grants(
+ request.app.state.config.USER_PERMISSIONS,
+ user.id,
+ user.role,
+ form_data.access_grants,
+ 'sharing.public_channels',
+ )
+
try:
channel = await Channels.update_channel_by_id(id, form_data, db=db)
return ChannelModel(**channel.model_dump())
diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py
index 99535cd296..3b52a30e17 100644
--- a/backend/open_webui/routers/files.py
+++ b/backend/open_webui/routers/files.py
@@ -23,7 +23,7 @@ from fastapi.params import Form as FormParam
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
-from open_webui.internal.db import get_async_session, SessionLocal
+from open_webui.internal.db import get_async_session, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@@ -155,7 +155,7 @@ async def process_uploaded_file(
if db:
await _process_handler(db)
else:
- with SessionLocal() as db_session:
+ async with get_async_db_context() as db_session:
await _process_handler(db_session)
@@ -555,7 +555,7 @@ async def update_file_data_content_by_id(
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db):
try:
- process_file(
+ await process_file(
request,
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
@@ -575,7 +575,7 @@ async def update_file_data_content_by_id(
# Remove old embeddings for this file from the KB collection
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
# Re-add from the now-updated file-{file_id} collection
- process_file(
+ await process_file(
request,
ProcessFileForm(file_id=id, collection_name=knowledge.id),
user=user,
diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py
index fb3bc1cec5..0d534db7f6 100644
--- a/backend/open_webui/routers/images.py
+++ b/backend/open_webui/routers/images.py
@@ -832,7 +832,7 @@ async def image_edits(
except Exception as e:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
- async def get_image_file_item(base64_string, param_name='image'):
+ def get_image_file_item(base64_string, param_name='image'):
data = base64_string
header, encoded = data.split(',', 1)
mime_type = header.split(';')[0].lstrip('data:')
diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py
index 75ccb98e20..6c7bdc64dc 100644
--- a/backend/open_webui/routers/knowledge.py
+++ b/backend/open_webui/routers/knowledge.py
@@ -2,7 +2,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse
-from fastapi.concurrency import run_in_threadpool
+
import logging
import io
import zipfile
@@ -320,8 +320,7 @@ async def reindex_knowledge_files(
failed_files = []
for file in files:
try:
- await run_in_threadpool(
- process_file,
+ await process_file(
request,
ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id),
user=user,
@@ -544,7 +543,7 @@ async def update_knowledge_access_by_id(
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return KnowledgeFilesResponse(
- **await Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(),
+ **(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(),
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
)
@@ -660,7 +659,7 @@ async def add_file_to_knowledge_by_id(
# Add content to the vector database
try:
- process_file(
+ await process_file(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
@@ -947,7 +946,7 @@ async def update_file_from_knowledge_by_id(
# Add content to the vector database
try:
- process_file(
+ await process_file(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
@@ -1196,7 +1195,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: As
log.debug(e)
pass
- knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db)
+ knowledge = await Knowledges.reset_knowledge_by_id(id=id, db=db)
return knowledge
diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py
index 11c916846a..93b34f5e66 100644
--- a/backend/open_webui/routers/ollama.py
+++ b/backend/open_webui/routers/ollama.py
@@ -50,7 +50,10 @@ from open_webui.models.groups import Groups
from open_webui.utils.access_control import check_model_access
from open_webui.utils.misc import (
calculate_sha256,
+)
+from open_webui.utils.session_pool import (
cleanup_response,
+ get_session,
stream_wrapper,
)
from open_webui.utils.payload import (
@@ -122,10 +125,7 @@ async def send_request(
r = None
streaming = False
try:
- session = aiohttp.ClientSession(
- trust_env=True,
- timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
- )
+ session = await get_session()
headers = {
'Content-Type': 'application/json',
@@ -140,6 +140,7 @@ async def send_request(
r = await session.request(
method, url, data=payload, headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
if not r.ok:
@@ -165,7 +166,7 @@ async def send_request(
streaming = True
return StreamingResponse(
- stream_wrapper(r, session),
+ stream_wrapper(r),
status_code=r.status,
headers=response_headers,
)
@@ -184,7 +185,7 @@ async def send_request(
)
finally:
if not streaming:
- await cleanup_response(r, session)
+ await cleanup_response(r)
def get_api_key(idx, url, configs):
diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py
index 51d4267c38..0bfddf47d1 100644
--- a/backend/open_webui/routers/openai.py
+++ b/backend/open_webui/routers/openai.py
@@ -52,9 +52,12 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.misc import (
- cleanup_response,
convert_logit_bias_input_to_json,
stream_chunks_handler,
+)
+from open_webui.utils.session_pool import (
+ cleanup_response,
+ get_session,
stream_wrapper,
)
@@ -1174,12 +1177,11 @@ async def generate_chat_completion(
payload = json.dumps(payload)
r = None
- session = None
streaming = False
response = None
try:
- session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT))
+ session = await get_session()
r = await session.request(
method='POST',
@@ -1188,13 +1190,14 @@ async def generate_chat_completion(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
- stream_wrapper(r, session, stream_chunks_handler),
+ stream_wrapper(r, content_handler=stream_chunks_handler),
status_code=r.status,
headers=dict(r.headers),
)
@@ -1225,7 +1228,7 @@ async def generate_chat_completion(
)
finally:
if not streaming:
- await cleanup_response(r, session)
+ await cleanup_response(r)
async def embeddings(request: Request, form_data: dict, user):
@@ -1261,27 +1264,24 @@ async def embeddings(request: Request, form_data: dict, user):
)
r = None
- session = None
streaming = False
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
try:
- session = aiohttp.ClientSession(
- trust_env=True,
- timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
- )
+ session = await get_session()
r = await session.request(
method='POST',
url=f'{url}/embeddings',
data=body,
headers=headers,
cookies=cookies,
+ timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
- stream_wrapper(r, session),
+ stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@@ -1306,7 +1306,7 @@ async def embeddings(request: Request, form_data: dict, user):
)
finally:
if not streaming:
- await cleanup_response(r, session)
+ await cleanup_response(r)
class ResponsesForm(BaseModel):
@@ -1365,7 +1365,6 @@ async def responses(
)
r = None
- session = None
streaming = False
try:
@@ -1388,10 +1387,7 @@ async def responses(
else:
request_url = f'{url}/responses'
- session = aiohttp.ClientSession(
- trust_env=True,
- timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
- )
+ session = await get_session()
r = await session.request(
method='POST',
url=request_url,
@@ -1399,13 +1395,14 @@ async def responses(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
- stream_wrapper(r, session),
+ stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@@ -1433,7 +1430,7 @@ async def responses(
)
finally:
if not streaming:
- await cleanup_response(r, session)
+ await cleanup_response(r)
@router.api_route('/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
@@ -1479,7 +1476,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
)
r = None
- session = None
streaming = False
try:
@@ -1508,10 +1504,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
else:
request_url = f'{url}/{path}'
- session = aiohttp.ClientSession(
- trust_env=True,
- timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
- )
+ session = await get_session()
r = await session.request(
method=request.method,
url=request_url,
@@ -1519,13 +1512,14 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
- stream_wrapper(r, session),
+ stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@@ -1553,4 +1547,4 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
)
finally:
if not streaming:
- await cleanup_response(r, session)
+ await cleanup_response(r)
diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py
index 091143a5d5..3253ab3707 100644
--- a/backend/open_webui/routers/users.py
+++ b/backend/open_webui/routers/users.py
@@ -40,6 +40,7 @@ from open_webui.utils.auth import (
validate_password,
)
from open_webui.utils.access_control import get_permissions, has_permission
+from open_webui.socket.main import disconnect_user_sessions
log = logging.getLogger(__name__)
@@ -620,6 +621,10 @@ async def update_user_by_id(
)
if updated_user:
+ # If the role changed, disconnect all socket sessions so stale
+ # privileges cached in SESSION_POOL are invalidated.
+ if updated_user.role != user.role:
+ await disconnect_user_sessions(user_id)
return updated_user
raise HTTPException(
@@ -659,6 +664,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Asyn
result = await Auths.delete_auth_by_id(user_id, db=db)
if result:
+ await disconnect_user_sessions(user_id)
return True
raise HTTPException(
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index d2ddd90b19..2c44eb25c5 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -312,6 +312,24 @@ async def enter_room_for_users(room: str, user_ids: list[str]):
log.debug(f'Failed to make users {user_ids} join room {room}: {e}')
+async def disconnect_user_sessions(user_id: str):
+ """Disconnect all Socket.IO sessions belonging to a user.
+
+ Call this when a user's role is changed or the user is deleted so that
+ stale role/permission data cached in SESSION_POOL is invalidated.
+ The client will automatically reconnect and re-authenticate with
+ fresh data from the database.
+ """
+ try:
+ session_ids = get_session_ids_from_room(f'user:{user_id}')
+ for sid in session_ids:
+ await sio.disconnect(sid)
+ if session_ids:
+ log.info(f'Disconnected {len(session_ids)} session(s) for user {user_id}')
+ except Exception as e:
+ log.warning(f'Failed to disconnect sessions for user {user_id}: {e}')
+
+
@sio.on('usage')
async def usage(sid, data):
if sid in SESSION_POOL:
diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py
index 58af934372..25d1f84413 100644
--- a/backend/open_webui/tools/builtin.py
+++ b/backend/open_webui/tools/builtin.py
@@ -1213,7 +1213,7 @@ async def search_channel_messages(
end_ts = end_timestamp * 1_000_000_000 if end_timestamp else None
# Search messages using the model method
- matching_messages = Messages.search_messages_by_channel_ids(
+ matching_messages = await Messages.search_messages_by_channel_ids(
channel_ids=channel_ids,
query=query,
start_timestamp=start_ts,
@@ -1274,7 +1274,7 @@ async def view_channel_message(
try:
user_id = __user__.get('id')
- message = Messages.get_message_by_id(message_id)
+ message = await Messages.get_message_by_id(message_id)
if not message:
return json.dumps({'error': 'Message not found'})
@@ -1336,7 +1336,7 @@ async def view_channel_thread(
user_id = __user__.get('id')
# Get the parent message
- parent_message = Messages.get_message_by_id(parent_message_id)
+ parent_message = await Messages.get_message_by_id(parent_message_id)
if not parent_message:
return json.dumps({'error': 'Message not found'})
@@ -1353,7 +1353,7 @@ async def view_channel_thread(
return json.dumps({'error': 'Access denied'})
# Get all thread replies
- thread_replies = Messages.get_thread_replies_by_message_id(parent_message_id)
+ thread_replies = await Messages.get_thread_replies_by_message_id(parent_message_id)
# Build the response
messages = []
diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py
index 1200d813af..5686c88d5d 100644
--- a/backend/open_webui/utils/audit.py
+++ b/backend/open_webui/utils/audit.py
@@ -24,7 +24,7 @@ from asgiref.typing import (
from loguru import logger
from starlette.requests import Request
-from open_webui.env import AUDIT_LOG_LEVEL, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE
+from open_webui.env import AUDIT_LOG_LEVEL, ENABLE_AUDIT_GET_REQUESTS, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
from open_webui.models.users import UserModel
@@ -117,7 +117,7 @@ class AuditLoggingMiddleware:
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
"""
- AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
+ DEFAULT_AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
def __init__(
self,
@@ -127,12 +127,16 @@ class AuditLoggingMiddleware:
included_paths: Optional[list[str]] = None,
max_body_size: int = MAX_BODY_LOG_SIZE,
audit_level: AuditLevel = AuditLevel.NONE,
+ audit_get_requests: bool = False,
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.included_paths = included_paths or []
self.max_body_size = max_body_size
+ self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
+ if audit_get_requests:
+ self.audited_methods.add('GET')
self.audit_level = audit_level
if self.included_paths and self.excluded_paths:
@@ -202,7 +206,10 @@ class AuditLoggingMiddleware:
return None
def _should_skip_auditing(self, request: Request) -> bool:
- if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE':
+ if AUDIT_LOG_LEVEL == 'NONE':
+ return True
+
+ if request.method not in self.audited_methods:
return True
ALWAYS_LOG_ENDPOINTS = {
diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py
index 32e7db3423..9ac7524411 100644
--- a/backend/open_webui/utils/auth.py
+++ b/backend/open_webui/utils/auth.py
@@ -427,6 +427,26 @@ async def get_current_user_by_api_key(request, api_key: str):
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
+ # Enforce endpoint restrictions — checked here (not in middleware)
+ # so it applies regardless of how the API key was transported
+ # (Authorization header, cookie, x-api-key header, etc.).
+ if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
+ allowed_paths = [
+ path.strip()
+ for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',')
+ if path.strip()
+ ]
+ request_path = request.url.path
+ is_allowed = any(
+ request_path == allowed or request_path.startswith(allowed + '/')
+ for allowed in allowed_paths
+ )
+ if not is_allowed:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+ )
+
# Add user info to current span
if ENABLE_OTEL:
from opentelemetry import trace
diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py
index 262430dcf0..45b7ba65ab 100644
--- a/backend/open_webui/utils/automations.py
+++ b/backend/open_webui/utils/automations.py
@@ -224,6 +224,16 @@ def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
return list(filter_ids) if filter_ids else []
+def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
+ """Read model default terminal_id from model config.
+
+ The frontend does this in Chat.svelte (model.info.meta.terminalId).
+ """
+ models = getattr(app.state, 'MODELS', {})
+ model = models.get(model_id, {})
+ return model.get('info', {}).get('meta', {}).get('terminalId') or None
+
+
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
"""Set the working directory on a terminal server via the proxy.
@@ -357,13 +367,8 @@ async def execute_automation(app, automation: AutomationModel) -> None:
features = _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
- # If a terminal is linked, set the CWD before building the payload
- terminal_id = None
- if terminal_config and terminal_config.get('server_id'):
- terminal_id = terminal_config['server_id']
- cwd = terminal_config.get('cwd')
- if cwd:
- await _set_terminal_cwd(app, terminal_id, user, cwd, chat.id)
+ # Resolve terminal from model config
+ terminal_id = _resolve_model_terminal_id(app, model_id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index fb4912bef5..ce75cd15fc 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -4060,11 +4060,12 @@ async def streaming_chat_response_handler(response, ctx):
if responses_api_tool_calls:
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
+ try:
+ await stream_body_handler(response, form_data)
+ finally:
if response.background:
await response.background()
- await stream_body_handler(response, form_data)
-
tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations
diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py
index 6c99f02905..fe81b61aa3 100644
--- a/backend/open_webui/utils/misc.py
+++ b/backend/open_webui/utils/misc.py
@@ -910,9 +910,11 @@ async def cleanup_response(
session: Optional[aiohttp.ClientSession],
):
if response:
- response.close()
+ if not response.closed:
+ await response.close()
if session:
- await session.close()
+ if not session.closed:
+ await session.close()
async def stream_wrapper(response, session, content_handler=None):
diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py
new file mode 100644
index 0000000000..86ffa6cd9d
--- /dev/null
+++ b/backend/open_webui/utils/session_pool.py
@@ -0,0 +1,114 @@
+"""Shared aiohttp ClientSession pool.
+
+Instead of creating a new ClientSession (and TCPConnector) per request,
+callers acquire a long-lived session from this module. The pool manages
+a single TCPConnector with configurable limits, enabling TCP/SSL connection
+reuse, shared DNS cache, and bounded concurrency.
+
+All pool parameters are configurable via environment variables:
+ - AIOHTTP_POOL_CONNECTIONS (default 100) — max total connections
+ - AIOHTTP_POOL_CONNECTIONS_PER_HOST (default 30) — per-host limit
+ - AIOHTTP_POOL_DNS_TTL (default 300) — DNS cache TTL in seconds
+
+Usage:
+ from open_webui.utils.session_pool import get_session, cleanup_response
+
+ session = await get_session()
+ r = await session.request(...)
+ # When done with the *response* (not the session):
+ await cleanup_response(r)
+
+IMPORTANT: Callers must NOT close the shared session. Only the response
+needs cleanup. The session is closed once during application shutdown
+via ``close_session()``.
+"""
+
+import logging
+from typing import Optional
+
+import aiohttp
+
+from open_webui.env import (
+ AIOHTTP_CLIENT_TIMEOUT,
+ AIOHTTP_POOL_CONNECTIONS,
+ AIOHTTP_POOL_CONNECTIONS_PER_HOST,
+ AIOHTTP_POOL_DNS_TTL,
+)
+
+log = logging.getLogger(__name__)
+
+_session: Optional[aiohttp.ClientSession] = None
+
+
+async def get_session() -> aiohttp.ClientSession:
+ """Return the shared aiohttp ClientSession, creating it lazily."""
+ global _session
+ if _session is None or _session.closed:
+ connector_kwargs = {
+ 'ttl_dns_cache': AIOHTTP_POOL_DNS_TTL,
+ 'enable_cleanup_closed': True,
+ }
+ if AIOHTTP_POOL_CONNECTIONS is not None:
+ connector_kwargs['limit'] = AIOHTTP_POOL_CONNECTIONS
+ else:
+ connector_kwargs['limit'] = 0 # aiohttp: 0 = unlimited
+ if AIOHTTP_POOL_CONNECTIONS_PER_HOST is not None:
+ connector_kwargs['limit_per_host'] = AIOHTTP_POOL_CONNECTIONS_PER_HOST
+ else:
+ connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited
+ connector = aiohttp.TCPConnector(**connector_kwargs)
+ timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
+ _session = aiohttp.ClientSession(
+ connector=connector,
+ timeout=timeout,
+ trust_env=True,
+ )
+ log.info(
+ 'Created shared aiohttp session pool '
+ '(limit=%s, per_host=%s, dns_ttl=%d)',
+ AIOHTTP_POOL_CONNECTIONS or 'unlimited',
+ AIOHTTP_POOL_CONNECTIONS_PER_HOST or 'unlimited',
+ AIOHTTP_POOL_DNS_TTL,
+ )
+ return _session
+
+
+async def close_session():
+ """Close the shared session. Called during application shutdown."""
+ global _session
+ if _session and not _session.closed:
+ await _session.close()
+ log.info('Closed shared aiohttp session pool')
+ _session = None
+
+
+async def cleanup_response(
+ response: Optional[aiohttp.ClientResponse],
+ session: Optional[aiohttp.ClientSession] = None,
+):
+ """Release and close an aiohttp response, optionally closing the session.
+
+ When using the shared pool, ``session`` should be ``None`` (the pool
+ session is never closed per-request). When a caller creates its own
+ one-off session, pass it here to close it after the response.
+ """
+ if response:
+ if not response.closed:
+ await response.close()
+ if session:
+ if not session.closed:
+ await session.close()
+
+
+async def stream_wrapper(response, session=None, content_handler=None):
+ """Wrap a stream to ensure cleanup happens even if streaming is interrupted.
+
+ This is more reliable than BackgroundTask which may not run if the client
+ disconnects. When using the shared pool, ``session`` should be ``None``.
+ """
+ try:
+ stream = content_handler(response.content) if content_handler else response.content
+ async for chunk in stream:
+ yield chunk
+ finally:
+ await cleanup_response(response, session)
diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py
index a44fe69ab8..fb2ec8ad30 100644
--- a/backend/open_webui/utils/tools.py
+++ b/backend/open_webui/utils/tools.py
@@ -806,7 +806,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
if not description:
description = param.get('description') or ''
if param_schema.get('enum') and isinstance(param_schema.get('enum'), list):
- description += f'. Possible values: {", ".join(param_schema.get("enum"))}'
+ description += f'. Possible values: {", ".join(str(v) for v in param_schema.get("enum"))}'
param_property = {
'type': param_schema.get('type') or 'string',
'description': description,
diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py
index eec53317f5..4decac5eb7 100644
--- a/backend/open_webui/utils/validate.py
+++ b/backend/open_webui/utils/validate.py
@@ -1,13 +1,31 @@
"""Validation utilities for user-supplied input."""
-# Known static asset paths used as default profile images
-_ALLOWED_STATIC_PATHS = (
- '/user.png',
- '/static/favicon.png',
+import re
+from urllib.parse import urlparse
+
+# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts
+# any user-ID without allowing path-traversal or query/fragment injection,
+# and the ``$`` anchor rejects trailing path components.
+_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$')
+
+# Validates MIME type and structure of base64 data URIs. Only the prefix
+# is checked — validating the full base64 payload would mean running a
+# regex across megabytes of data on every Pydantic instantiation for zero
+# security benefit (corrupt base64 simply renders a broken image, same as
+# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts.
+_SAFE_DATA_URI_RE = re.compile(
+ r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE
)
-# External URL prefixes that are explicitly trusted for profile images
-_ALLOWED_URL_PREFIXES = ('https://www.gravatar.com/avatar/',)
+# Exact relative paths accepted as profile images. These are the only
+# static-asset paths OWUI itself assigns; no prefix/wildcard matching is
+# used so that arbitrary relative paths cannot trigger authenticated GETs
+# against internal endpoints when rendered as ```` sources.
+_SAFE_STATIC_PATHS = frozenset({
+ '/user.png',
+ '/favicon.png',
+ '/static/favicon.png',
+})
def validate_profile_image_url(url: str) -> str:
@@ -16,28 +34,51 @@ def validate_profile_image_url(url: str) -> str:
Allowed formats:
- Empty string (falls back to default avatar)
- - data:image/* URIs (base64-encoded uploads from the frontend)
- - Known static asset paths (/user.png, /static/favicon.png)
- - Trusted external URLs (e.g. Gravatar)
+ - Known static-asset paths assigned by OWUI (exact match)
+ - The OWUI profile-image API route ``/api/v1/users/{id}/profile/image``
+ - ``http://`` and ``https://`` URLs with a valid hostname
+ - ``data:image/{png,jpeg,gif,webp};base64,...`` URIs
- Returns the url unchanged if valid, raises ValueError otherwise.
+ Everything else is rejected, including:
+ - Dangerous schemes (javascript:, file:, ftp:, …)
+ - SVG data URIs (can contain embedded scripts)
+ - Arbitrary relative paths (prevents authenticated GET triggers)
+ - Scheme-relative URLs (``//host/path``)
"""
if not url:
return url
- _ALLOWED_DATA_PREFIXES = (
- 'data:image/png',
- 'data:image/jpeg',
- 'data:image/gif',
- 'data:image/webp',
+ # --- Relative paths (exact match + anchored regex only) -----------
+
+ if url in _SAFE_STATIC_PATHS:
+ return url
+
+ if _USER_PROFILE_IMAGE_RE.match(url):
+ return url
+
+ # --- Absolute URLs -------------------------------------------------
+
+ # urlparse normalises the scheme to lowercase, giving us
+ # case-insensitive scheme matching for free.
+ parsed = urlparse(url)
+
+ # External images served over HTTP(S), e.g. OAuth provider avatars.
+ # Require a non-empty hostname (not just netloc, which can be ":80"
+ # for a URL like http://:80/path with no actual host).
+ if parsed.scheme in ('http', 'https'):
+ if not parsed.hostname:
+ raise ValueError(
+ 'Invalid profile image URL: HTTP(S) URLs must include a host.'
+ )
+ return url
+
+ # Base64-encoded raster images uploaded via the frontend.
+ # The regex enforces the ;base64, boundary and is case-insensitive
+ # per the data-URI / MIME-type specs.
+ if _SAFE_DATA_URI_RE.match(url):
+ return url
+
+ raise ValueError(
+ 'Invalid profile image URL: must be a known internal path, '
+ 'an HTTP(S) URL with a host, or a data:image URI (png/jpeg/gif/webp).'
)
- if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES):
- return url
-
- if url in _ALLOWED_STATIC_PATHS:
- return url
-
- if any(url.startswith(prefix) for prefix in _ALLOWED_URL_PREFIXES):
- return url
-
- raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.')
diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts
index 05475b4b37..5faf56d4d4 100644
--- a/src/lib/apis/index.ts
+++ b/src/lib/apis/index.ts
@@ -273,10 +273,42 @@ export const stopTask = async (token: string, id: string) => {
return res;
};
+export const stopTasksByChatId = async (token: string, chat_id: string) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}/stop`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ ...(token && { authorization: `Bearer ${token}` })
+ }
+ })
+ .then(async (res) => {
+ if (!res.ok) throw await res.json();
+ return res.json();
+ })
+ .catch((err) => {
+ console.error(err);
+ if ('detail' in err) {
+ error = err.detail;
+ } else {
+ error = err;
+ }
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
let error = null;
- const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${chat_id}`, {
+ const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}`, {
method: 'GET',
headers: {
Accept: 'application/json',
diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte
index c1843f20e7..c16265515e 100644
--- a/src/lib/components/AutomationModal.svelte
+++ b/src/lib/components/AutomationModal.svelte
@@ -8,7 +8,6 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
- import TerminalDropdown from '$lib/components/automations/TerminalDropdown.svelte';
import {
createAutomation,
@@ -16,7 +15,6 @@
type AutomationForm,
type AutomationResponse
} from '$lib/apis/automations';
- import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -31,11 +29,6 @@
let loading = false;
- // Terminal state
- let terminalServers: TerminalServer[] = [];
- let terminalServerId = '';
- let terminalCwd = '';
-
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@@ -58,15 +51,7 @@
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
- rrule: scheduleDropdown.buildRrule(),
- ...(terminalServerId
- ? {
- terminal: {
- server_id: terminalServerId,
- ...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
- }
- }
- : {})
+ rrule: scheduleDropdown.buildRrule()
},
is_active
};
@@ -90,20 +75,11 @@
};
const init = async () => {
- // Load terminal servers
- try {
- terminalServers = await getTerminalServers(localStorage.token);
- } catch {
- terminalServers = [];
- }
-
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
- terminalServerId = automation.data.terminal?.server_id || '';
- terminalCwd = automation.data.terminal?.cwd || '';
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
@@ -112,8 +88,6 @@
prompt = '';
model_id = '';
is_active = true;
- terminalServerId = '';
- terminalCwd = '';
}
};
@@ -158,14 +132,6 @@