diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py
index f16f016e9..d31376dc3 100644
--- a/routes/assistant_routes.py
+++ b/routes/assistant_routes.py
@@ -16,6 +16,7 @@ from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
+from src.endpoint_resolver import resolve_owner_registered_endpoint_url
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_scheduler import compute_next_run
@@ -178,7 +179,13 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
if payload.model is not None:
crew_db.model = payload.model or None
if payload.endpoint_url is not None:
- crew_db.endpoint_url = payload.endpoint_url or None
+ try:
+ crew_db.endpoint_url = (
+ resolve_owner_registered_endpoint_url(db, payload.endpoint_url, owner)
+ if payload.endpoint_url else None
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
if payload.timezone is not None:
crew_db.timezone = payload.timezone or None
diff --git a/routes/skills_routes.py b/routes/skills_routes.py
index ec5a49876..87d8cb435 100644
--- a/routes/skills_routes.py
+++ b/routes/skills_routes.py
@@ -2003,7 +2003,11 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
it untouched). It never changes the skill's published/draft STATUS."""
import time as _time
import asyncio as _asyncio
- from src.endpoint_resolver import resolve_endpoint
+ from core.database import SessionLocal
+ from src.endpoint_resolver import (
+ build_chat_url, build_headers, resolve_endpoint,
+ resolve_endpoint_runtime, resolve_owner_registered_endpoint,
+ )
user = _owner(request)
body = await request.json()
@@ -2027,10 +2031,18 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
# session's model. Fall back to the caller's session model only if unset.
url, model, headers = resolve_endpoint("utility", owner=user)
if not url or not model:
- url = url or ((body.get("endpoint_url") or "").strip() or None)
+ if not url and body.get("endpoint_url") is not None:
+ db = SessionLocal()
+ try:
+ endpoint = resolve_owner_registered_endpoint(db, body["endpoint_url"], user)
+ base, api_key = resolve_endpoint_runtime(endpoint, owner=user)
+ url = build_chat_url(base)
+ headers = build_headers(api_key, base)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ finally:
+ db.close()
model = model or ((body.get("model") or "").strip() or None)
- if headers is None and isinstance(body.get("headers"), dict):
- headers = body.get("headers")
if not url or not model:
raise HTTPException(400, "No model configured — set a Default or Utility model in Settings.")
diff --git a/routes/task/task_routes.py b/routes/task/task_routes.py
index 0749cb548..a1179d50e 100644
--- a/routes/task/task_routes.py
+++ b/routes/task/task_routes.py
@@ -14,6 +14,7 @@ from core.database import SessionLocal, ScheduledTask, TaskRun, NotificationLog
from core.constants import internal_api_base
from src.auth_helpers import get_current_user
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
+from src.endpoint_resolver import resolve_owner_registered_endpoint_url
from src.task_action_policy import (
ADMIN_ONLY_TASK_ACTIONS,
is_admin_only_task_action,
@@ -519,6 +520,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
db = SessionLocal()
try:
then_task_id = _validate_then_task_id(db, req.then_task_id, user)
+ endpoint_url = None
+ if req.endpoint_url:
+ try:
+ endpoint_url = resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
notifications_enabled = (
False if req.task_type == "action" and req.notifications_enabled is None
else bool(req.notifications_enabled) if req.notifications_enabled is not None
@@ -555,7 +562,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
status="active" if (req.trigger_type in ("event", "webhook") or next_run) else "completed",
output_target=req.output_target,
model=req.model or None,
- endpoint_url=req.endpoint_url or None,
+ endpoint_url=endpoint_url,
then_task_id=then_task_id,
webhook_token=webhook_token,
notifications_enabled=notifications_enabled,
@@ -755,7 +762,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.model is not None:
task.model = req.model or None
if req.endpoint_url is not None:
- task.endpoint_url = req.endpoint_url or None
+ try:
+ # An empty override restores the existing default-model workflow.
+ task.endpoint_url = (
+ resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
+ if req.endpoint_url else None
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
if req.trigger_type is not None:
# Generate webhook token when switching to webhook trigger
if req.trigger_type == "webhook" and not task.webhook_token:
diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py
index d85a53f18..e83ee97e0 100644
--- a/src/endpoint_resolver.py
+++ b/src/endpoint_resolver.py
@@ -281,6 +281,29 @@ def same_endpoint_base(left, right) -> bool:
return False
+def resolve_owner_registered_endpoint(db, endpoint_url: str, owner: Optional[str] = None):
+ """Authorize a caller URL against enabled, owner-visible endpoint rows.
+
+ Request credentials, query strings and fragments are never endpoint identity.
+ Return the server-owned row so runtime credentials come from registration.
+ """
+ from src.auth_helpers import owner_filter
+
+ if not isinstance(endpoint_url, str) or not same_endpoint_base(endpoint_url, endpoint_url):
+ raise ValueError("Invalid model endpoint URL")
+ query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled.is_(True))
+ for endpoint in owner_filter(query, ModelEndpoint, owner).all():
+ if same_endpoint_base(endpoint_url, endpoint.base_url):
+ return endpoint
+ raise ValueError("Model endpoint must be enabled and registered for the current owner")
+
+
+def resolve_owner_registered_endpoint_url(db, endpoint_url: str, owner: Optional[str] = None) -> str:
+ """Return only the registered canonical base, never the caller's URL."""
+ endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
+ return normalize_base(endpoint.base_url)
+
+
def _validated_endpoint_base(url: str) -> str:
"""Return a base URL that is safe for endpoint path appends."""
base = (url or "").strip().rstrip("/")
diff --git a/static/js/markdown.js b/static/js/markdown.js
index cabb624b3..b992c0abb 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -791,12 +791,13 @@ function renderSvgSandbox(source) {
const height = viewBox ? Number(viewBox[2]) : 9;
const ratio = Number.isFinite(width / height) && width > 0 && height > 0
? Math.max(0.5, Math.min(3, width / height)) : (16 / 9);
- // Parse in an inert template: nested/malformed SVG title markup stays text.
+ // XML parsing extracts text without inserting title markup into an HTML DOM.
let title = 'Visual explanation';
- if (typeof document !== 'undefined') {
- const template = document.createElement('template');
- template.innerHTML = cleaned;
- title = template.content.querySelector?.('svg title')?.textContent?.trim() || title;
+ if (typeof DOMParser !== 'undefined') {
+ const svg = new DOMParser().parseFromString(cleaned, 'image/svg+xml');
+ if (!svg.querySelector('parsererror')) {
+ title = svg.querySelector('svg title')?.textContent?.trim() || title;
+ }
}
const csp = "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'";
const srcdoc = `