mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 15:52:20 +02:00
fix(security): harden Python service boundaries
This commit is contained in:
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
|
||||
lower = s.lower().strip()
|
||||
|
||||
def _parse_time(t):
|
||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||
if not m: return None
|
||||
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
||||
@@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str:
|
||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||
|
||||
# Time-first: "3pm today", "11pm today", "9am tomorrow"
|
||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
if m:
|
||||
time_part, word = m.group(1).strip(), m.group(2)
|
||||
base = today
|
||||
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
|
||||
|
||||
def _parse_time(t: str):
|
||||
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
|
||||
|
||||
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
||||
# (parity with parse_due_for_user, which handles these via the same form)
|
||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
if m:
|
||||
time_part, word = m.group(1).strip(), m.group(2)
|
||||
base = today
|
||||
|
||||
@@ -98,7 +98,9 @@ def clean_repeated_assistant_content(text: object) -> str:
|
||||
"",
|
||||
value,
|
||||
).strip()
|
||||
value = re.sub(r"(?is)\s*</\s*think\s*>\s*$", "", value).strip()
|
||||
# No leading `\s*`: .strip() removes that whitespace anyway, and scanning
|
||||
# it from every offset of a long whitespace run was quadratic (ReDoS).
|
||||
value = re.sub(r"(?is)</\s*think\s*>\s*$", "", value).strip()
|
||||
return value
|
||||
|
||||
_CASUAL_OPENING_RE = re.compile(
|
||||
@@ -1324,13 +1326,17 @@ def _normalize_thinking(text: str) -> str:
|
||||
|
||||
# Handle garbled <think> tags: reasoning text followed by <think> as separator
|
||||
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
|
||||
# Linear form of `^([\s\S]+?)\n*<think>\s*([\s\S]*?)(?:</think>)?\s*$`:
|
||||
# the lookbehind stops the lazy prefix re-scanning a newline run from every
|
||||
# offset, and the optional trailing closer is dropped after the match
|
||||
# instead of being retried at every body offset (both were quadratic).
|
||||
garbled = re.match(
|
||||
r'^([\s\S]+?)\n*<think(?:ing)?>\s*([\s\S]*?)(?:</think(?:ing)?>)?\s*$',
|
||||
r'^([\s\S]+?)(?<![\s\S]\n)\n*<think(?:ing)?>\s*([\s\S]*)$',
|
||||
text, re.IGNORECASE
|
||||
)
|
||||
if garbled:
|
||||
before = garbled.group(1).strip()
|
||||
after = garbled.group(2).strip()
|
||||
after = re.sub(r'</think(?:ing)?>$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip()
|
||||
# Only treat as garbled if the part before <think> looks like reasoning
|
||||
reasoning_starts = (
|
||||
'The user ', 'I need ', 'I should ', 'I will ',
|
||||
|
||||
+20
-2
@@ -327,13 +327,31 @@ def _is_external_discovery_request(text: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||
|
||||
|
||||
def _mentions_pdf_url(value: str) -> bool:
|
||||
"""Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``.
|
||||
|
||||
Checked per whitespace-free token from its first scheme only (a later
|
||||
scheme's tail is a suffix of the first's), so a token packed with
|
||||
`http://` repeats is scanned once instead of once per repeat (ReDoS).
|
||||
"""
|
||||
for token in value.split():
|
||||
scheme = _URL_SCHEME_RE.search(token)
|
||||
if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _prefers_structured_document_tools(text: str) -> bool:
|
||||
"""Identify external paper/PDF extraction where shell is a bad source route."""
|
||||
value = str(text or "")
|
||||
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
|
||||
return False
|
||||
return bool(
|
||||
re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
|
||||
_mentions_pdf_url(value)
|
||||
or re.search(
|
||||
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
|
||||
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
|
||||
@@ -2507,7 +2525,7 @@ def setup_chat_routes(
|
||||
_explicit_web_intent = (
|
||||
_explicit_url_target
|
||||
or bool(re.search(
|
||||
r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||
r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||
_msg_l,
|
||||
))
|
||||
or requires_external_web_verification(message)
|
||||
|
||||
+153
-16
@@ -17,6 +17,8 @@ import base64
|
||||
import time
|
||||
import imaplib
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import email as email_mod
|
||||
import email.header
|
||||
import email.utils
|
||||
@@ -36,6 +38,7 @@ from typing import Optional, List
|
||||
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.secret_storage import decrypt as _decrypt
|
||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -160,6 +163,94 @@ def _smtp_security_mode(cfg: dict) -> str:
|
||||
return "ssl"
|
||||
|
||||
|
||||
# Raised when a mail host resolves into a denied range (see _mail_private_blocked).
|
||||
MailServerAddressBlocked = OutboundAddressBlocked
|
||||
|
||||
|
||||
def _mail_private_blocked(owner: str | None) -> bool:
|
||||
"""Whether private/loopback/shared mail destinations are denied for *owner*.
|
||||
|
||||
Link-local (cloud metadata), multicast, reserved and unspecified addresses
|
||||
are always denied (src/url_safety.py). Private ranges are where a mail
|
||||
tester or saved account becomes an internal-network probe, so they are
|
||||
only allowed for an admin or in single-user mode (AUTH_ENABLED=false), the
|
||||
same principals trusted with host-level tools. Operators of multi-user
|
||||
deployments with a LAN mail server opt in with EMAIL_ALLOW_PRIVATE_IPS=true;
|
||||
EMAIL_BLOCK_PRIVATE_IPS=true denies private ranges for everyone.
|
||||
"""
|
||||
if os.getenv("EMAIL_BLOCK_PRIVATE_IPS", "").strip().lower() == "true":
|
||||
return True
|
||||
if os.getenv("EMAIL_ALLOW_PRIVATE_IPS", "").strip().lower() == "true":
|
||||
return False
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
return not owner_is_admin_or_single_user((owner or "").strip() or None)
|
||||
|
||||
|
||||
def _mail_socket_timeout(timeout):
|
||||
return socket._GLOBAL_DEFAULT_TIMEOUT if timeout is None else timeout
|
||||
|
||||
|
||||
class _PolicyIMAP4(imaplib.IMAP4):
|
||||
"""IMAP4 whose socket comes from connect_outbound_tcp.
|
||||
|
||||
The mail host is resolved once and connected to only at the addresses the
|
||||
address policy approved, so DNS rebinding between check and connect is
|
||||
impossible. STARTTLS still verifies against ``self.host``.
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, *, block_private: bool, timeout=None):
|
||||
self._block_private = block_private
|
||||
super().__init__(host, port, timeout=timeout)
|
||||
|
||||
def _create_socket(self, timeout):
|
||||
if timeout is not None and not timeout:
|
||||
raise ValueError("Non-blocking socket (timeout=0) is not supported")
|
||||
return connect_outbound_tcp(
|
||||
self.host, self.port, timeout=_mail_socket_timeout(timeout), block_private=self._block_private,
|
||||
)
|
||||
|
||||
|
||||
class _PolicyIMAP4_SSL(imaplib.IMAP4_SSL):
|
||||
"""IMAP4_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
|
||||
|
||||
def __init__(self, host, port, *, block_private: bool, timeout=None, ssl_context=None):
|
||||
self._block_private = block_private
|
||||
super().__init__(host, port, ssl_context=ssl_context, timeout=timeout)
|
||||
|
||||
def _create_socket(self, timeout):
|
||||
sock = _PolicyIMAP4._create_socket(self, timeout)
|
||||
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
|
||||
|
||||
|
||||
class _PolicySMTP(smtplib.SMTP):
|
||||
"""SMTP whose socket comes from connect_outbound_tcp (see _PolicyIMAP4)."""
|
||||
|
||||
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
|
||||
self._block_private = block_private
|
||||
super().__init__(host, port, **kwargs)
|
||||
|
||||
def _get_socket(self, host, port, timeout):
|
||||
if timeout is not None and not timeout:
|
||||
raise ValueError("Non-blocking socket (timeout=0) is not supported")
|
||||
return connect_outbound_tcp(
|
||||
host, port, timeout=timeout, block_private=self._block_private,
|
||||
source_address=self.source_address,
|
||||
)
|
||||
|
||||
|
||||
class _PolicySMTP_SSL(smtplib.SMTP_SSL):
|
||||
"""SMTP_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
|
||||
|
||||
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
|
||||
self._block_private = block_private
|
||||
super().__init__(host, port, **kwargs)
|
||||
|
||||
def _get_socket(self, host, port, timeout):
|
||||
sock = _PolicySMTP._get_socket(self, host, port, timeout)
|
||||
return self.context.wrap_socket(sock, server_hostname=self._host)
|
||||
|
||||
|
||||
def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
|
||||
"""Send through SMTP using the configured transport security mode."""
|
||||
host = cfg["smtp_host"]
|
||||
@@ -178,14 +269,15 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
|
||||
smtp.login(user, password)
|
||||
|
||||
security = _smtp_security_mode(cfg)
|
||||
block_private = _mail_private_blocked(cfg.get("owner"))
|
||||
|
||||
if security == "ssl":
|
||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||
with _PolicySMTP_SSL(host, port, timeout=timeout, block_private=block_private) as smtp:
|
||||
_auth_smtp(smtp)
|
||||
smtp.sendmail(from_addr, recipients, message)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||
with _PolicySMTP(host, port, timeout=timeout, block_private=block_private) as smtp:
|
||||
if security == "starttls":
|
||||
smtp.starttls()
|
||||
_auth_smtp(smtp)
|
||||
@@ -224,6 +316,39 @@ def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
|
||||
return raw[:200]
|
||||
|
||||
|
||||
# Bound at import: callers (and tests) may swap out imaplib.IMAP4 itself.
|
||||
_IMAP_ABORT = imaplib.IMAP4.abort
|
||||
|
||||
|
||||
def _mail_connection_test_error(protocol: str, host: str, error: BaseException) -> str:
|
||||
"""User-facing result for a failed IMAP/SMTP connection test.
|
||||
|
||||
Transport failures are reported by category, never with the peer's own
|
||||
bytes: a non-mail service answering on the chosen host/port would
|
||||
otherwise have its banner echoed back (an internal-service fingerprinting
|
||||
oracle). Errors from a server that does speak the protocol (login/auth
|
||||
rejections) keep their text via _friendly_email_auth_error, since that is
|
||||
what the user needs to fix their settings.
|
||||
"""
|
||||
if isinstance(error, MailServerAddressBlocked):
|
||||
return f"{protocol} server address is not allowed by this server's network policy"
|
||||
if isinstance(error, (_IMAP_ABORT, smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected)):
|
||||
return f"{protocol} server did not respond like an {protocol} server"
|
||||
if isinstance(error, ssl.SSLCertVerificationError):
|
||||
return f"{protocol} TLS certificate verification failed"
|
||||
if isinstance(error, ssl.SSLError):
|
||||
return f"{protocol} TLS handshake failed; check the port and security setting"
|
||||
if isinstance(error, (socket.timeout, TimeoutError)):
|
||||
return f"{protocol} connection timed out"
|
||||
if isinstance(error, ConnectionRefusedError):
|
||||
return f"{protocol} connection refused"
|
||||
if isinstance(error, socket.gaierror):
|
||||
return f"{protocol} server name could not be resolved"
|
||||
if isinstance(error, OSError) and not isinstance(error, smtplib.SMTPException):
|
||||
return f"{protocol} connection failed"
|
||||
return _friendly_email_auth_error(protocol, host, error)
|
||||
|
||||
|
||||
def _strip_think(text: str) -> str:
|
||||
"""Email-flavored think strip — thin wrapper over the central helper.
|
||||
|
||||
@@ -682,18 +807,21 @@ def _ensure_sender_signatures_table(conn):
|
||||
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
|
||||
|
||||
|
||||
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
||||
"""Containment-safe extraction directory for an attachment.
|
||||
def attachment_extract_dir(folder: str, uid: str, *, owner: str, account_id: str | None) -> Path:
|
||||
"""Containment-safe extraction directory for one message's attachments.
|
||||
|
||||
`folder` and `uid` are user-controlled (query/path params). Flatten them to
|
||||
a single safe path segment so a value like folder='../../tmp' can't escape
|
||||
ATTACHMENTS_DIR, then assert containment as belt-and-suspenders."""
|
||||
key = re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") or "_"
|
||||
target = (ATTACHMENTS_DIR / key).resolve()
|
||||
base = ATTACHMENTS_DIR.resolve()
|
||||
if target != base and base not in target.parents:
|
||||
IMAP UIDs are small per-mailbox counters, so `folder_uid` alone put every
|
||||
user's and every account's "INBOX 42" in one directory, where extractions
|
||||
overwrote each other by filename (a path handed to one user's agent could
|
||||
then hold another user's attachment). See src.mail_attachment_paths: the
|
||||
directory is keyed by (owner, account, folder, uid) and contained in
|
||||
ATTACHMENTS_DIR whatever `folder`/`uid` hold."""
|
||||
from src.mail_attachment_paths import attachment_scope_dir
|
||||
|
||||
try:
|
||||
return attachment_scope_dir(ATTACHMENTS_DIR, folder, uid, owner=owner, account_id=account_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid attachment location")
|
||||
return target
|
||||
|
||||
|
||||
def _init_scheduled_db():
|
||||
@@ -1074,6 +1202,8 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
||||
cfg = {
|
||||
"account_id": row.id,
|
||||
"account_name": row.name,
|
||||
# Principal for the outbound mail address policy.
|
||||
"owner": row.owner or owner or "",
|
||||
"smtp_host": row.smtp_host or "",
|
||||
"smtp_port": int(row.smtp_port or 465),
|
||||
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
|
||||
@@ -1107,6 +1237,7 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
||||
cfg = {
|
||||
"account_id": resolved_id,
|
||||
"account_name": "legacy",
|
||||
"owner": owner or "",
|
||||
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
|
||||
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
|
||||
"smtp_security": _smtp_security_mode({
|
||||
@@ -1170,11 +1301,16 @@ def _open_imap_connection(
|
||||
starttls: bool,
|
||||
timeout: int = _IMAP_TIMEOUT_SECONDS,
|
||||
ssl_context=None,
|
||||
owner: str | None = None,
|
||||
):
|
||||
"""Open an IMAP connection using the configured security mode."""
|
||||
"""Open an IMAP connection using the configured security mode.
|
||||
|
||||
`owner` is the principal the destination is judged for
|
||||
(see _mail_private_blocked)."""
|
||||
port = int(port or 993)
|
||||
block_private = _mail_private_blocked(owner)
|
||||
if starttls:
|
||||
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
|
||||
try:
|
||||
if ssl_context:
|
||||
conn.starttls(ssl_context=ssl_context)
|
||||
@@ -1190,9 +1326,9 @@ def _open_imap_connection(
|
||||
raise
|
||||
elif port == 993:
|
||||
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
|
||||
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
|
||||
conn = _PolicyIMAP4_SSL(host, port, timeout=timeout, block_private=block_private, **kwargs)
|
||||
else:
|
||||
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
|
||||
try:
|
||||
conn.sock.settimeout(timeout)
|
||||
except Exception:
|
||||
@@ -1232,6 +1368,7 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
|
||||
cfg["imap_port"],
|
||||
starttls=bool(cfg.get("imap_starttls")),
|
||||
timeout=timeout,
|
||||
owner=cfg.get("owner"),
|
||||
)
|
||||
try:
|
||||
if cfg.get("oauth_provider") == "google":
|
||||
|
||||
+206
-153
@@ -36,6 +36,7 @@ from pathlib import Path
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from src.constants import DATA_DIR
|
||||
@@ -60,6 +61,7 @@ from .email_helpers import (
|
||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
||||
_mail_connection_test_error, _mail_private_blocked, _PolicySMTP, _PolicySMTP_SSL,
|
||||
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||
SendEmailRequest, ExtractStyleRequest,
|
||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||
@@ -1523,6 +1525,44 @@ def _envelope_recipients(*fields: str) -> list:
|
||||
return out
|
||||
|
||||
|
||||
_MD_LINK_URL_RE = re.compile(r"\((https?://)[^)\s]*")
|
||||
|
||||
|
||||
def _md_links_to_html(s: str) -> str:
|
||||
"""Linear ``re.sub(r"\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)", '<a href="\\2">\\1</a>', s)``.
|
||||
|
||||
Every `[` before the next `]` shares that `]`, so only the first can match;
|
||||
and when a URL runs into whitespace/end without `)`, every later `[` whose
|
||||
`]` also lies before that stop fails the same way. Skipping those spans
|
||||
keeps a `[[[` or `[a](http://x[a](http://x` flood O(n) instead of the
|
||||
regex's O(n^2).
|
||||
"""
|
||||
out: list[str] = []
|
||||
emitted = scan = 0
|
||||
while True:
|
||||
open_at = s.find("[", scan)
|
||||
if open_at < 0:
|
||||
break
|
||||
close_at = s.find("]", open_at + 1)
|
||||
if close_at < 0:
|
||||
break
|
||||
scan = close_at + 1
|
||||
if close_at == open_at + 1:
|
||||
continue
|
||||
url = _MD_LINK_URL_RE.match(s, close_at + 1)
|
||||
if url is None:
|
||||
continue
|
||||
end = url.end()
|
||||
if end < len(s) and s[end] == ")" and end > url.end(1):
|
||||
out.append(s[emitted:open_at])
|
||||
out.append(f'<a href="{s[url.start(1):end]}">{s[open_at + 1:close_at]}</a>')
|
||||
emitted = scan = end + 1
|
||||
elif end > url.end(1):
|
||||
scan = max(scan, s.rfind("]", scan, end) + 1)
|
||||
out.append(s[emitted:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _md_to_email_html(text: str) -> str:
|
||||
"""Render the compose markdown body to a SAFE HTML fragment for the email's
|
||||
text/html part. Everything is HTML-escaped FIRST (so a pasted <script> /
|
||||
@@ -1538,7 +1578,7 @@ def _md_to_email_html(text: str) -> str:
|
||||
s = re.sub(r"~~([^~]+)~~", r"<del>\1</del>", s)
|
||||
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
# links: text + http(s) url only (escape() already neutralised quotes)
|
||||
s = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2">\1</a>', s)
|
||||
s = _md_links_to_html(s)
|
||||
return s
|
||||
|
||||
parts: list[str] = []
|
||||
@@ -3900,7 +3940,7 @@ def setup_email_routes():
|
||||
_row, att = fixture_att
|
||||
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
filepath = target_dir / safe_name
|
||||
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
||||
@@ -3919,7 +3959,7 @@ def setup_email_routes():
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
|
||||
# Extract to a per-email folder
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||
if not filepath:
|
||||
return {"error": f"Attachment index {index} not found"}
|
||||
@@ -3951,7 +3991,7 @@ def setup_email_routes():
|
||||
if not attachments:
|
||||
raise HTTPException(status_code=404, detail="No downloadable attachments")
|
||||
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
zip_buf = io.BytesIO()
|
||||
used_names: dict[str, int] = {}
|
||||
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
@@ -4091,7 +4131,7 @@ def setup_email_routes():
|
||||
att for att in _list_attachments_from_msg(msg)
|
||||
if not _is_likely_signature_image_attachment(att)
|
||||
]
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=acct)
|
||||
for att in attachments:
|
||||
idx = att.get("index")
|
||||
if idx is None:
|
||||
@@ -4156,7 +4196,7 @@ def setup_email_routes():
|
||||
continue
|
||||
if not ct.lower().startswith("image/"):
|
||||
raise HTTPException(status_code=415, detail="Content-ID is not an image")
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
filepath = _extract_attachment_to_disk(msg, idx, target_dir)
|
||||
if not filepath:
|
||||
raise HTTPException(status_code=404, detail="Inline image not found")
|
||||
@@ -4194,7 +4234,7 @@ def setup_email_routes():
|
||||
raw = msg_data[0][1]
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||
if not filepath:
|
||||
return {"error": f"Attachment index {index} not found"}
|
||||
@@ -4522,7 +4562,9 @@ def setup_email_routes():
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to read text file: {e}", "filename": base}
|
||||
# OSError text includes the server-side extraction path.
|
||||
logger.warning("Failed to read text attachment %s: %s", base, e)
|
||||
return {"error": "Failed to read text file", "filename": base}
|
||||
doc_id = _create_markdown_doc(content, "Imported from email attachment")
|
||||
return {"doc_id": doc_id, "filename": filepath.name}
|
||||
|
||||
@@ -4539,7 +4581,7 @@ def setup_email_routes():
|
||||
_row, att = fixture_att
|
||||
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
filepath = target_dir / safe_name
|
||||
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
||||
@@ -4553,7 +4595,7 @@ def setup_email_routes():
|
||||
raw = msg_data[0][1]
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||
if not filepath:
|
||||
return {"error": f"Attachment index {index} not found"}
|
||||
@@ -5162,7 +5204,7 @@ def setup_email_routes():
|
||||
return {"success": False, "error": "Email not found"}
|
||||
raw = msg_data[0][1]
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||
if not filepath:
|
||||
return {"success": False, "error": f"Attachment index {index} not found"}
|
||||
@@ -6860,6 +6902,158 @@ def setup_email_routes():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _test_mail_connections(body: dict, owner: str) -> dict:
|
||||
"""Blocking IMAP/SMTP connection test for /accounts/test.
|
||||
|
||||
Runs in the threadpool: each probe can block for its full socket
|
||||
timeout, which on the event loop stalled every other request. The
|
||||
connections go through the outbound mail address policy for `owner`
|
||||
(the user who chose the hosts)."""
|
||||
imap_result = {"ok": False}
|
||||
smtp_result = None
|
||||
|
||||
imap_host = (body.get("imap_host") or "").strip()
|
||||
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
||||
imap_user = (body.get("imap_user") or "").strip()
|
||||
imap_pass = body.get("imap_password") or ""
|
||||
imap_starttls = bool(body.get("imap_starttls"))
|
||||
oauth_provider = body.get("oauth_provider") or ""
|
||||
|
||||
google_token = None
|
||||
google_token_loaded = False
|
||||
google_ssl_context = (
|
||||
ssl.create_default_context()
|
||||
if oauth_provider == "google"
|
||||
else None
|
||||
)
|
||||
|
||||
def _google_token():
|
||||
nonlocal google_token, google_token_loaded
|
||||
if not google_token_loaded:
|
||||
google_token = _get_valid_google_token(body.get("account_id"), body)
|
||||
google_token_loaded = True
|
||||
if not google_token:
|
||||
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
||||
return google_token
|
||||
|
||||
if imap_port_err:
|
||||
imap_result = {"ok": False, "error": imap_port_err}
|
||||
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
|
||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
|
||||
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
|
||||
else:
|
||||
# Connection mode resolution:
|
||||
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
|
||||
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
|
||||
# STARTTLS off + any other port → plain IMAP4 (no encryption)
|
||||
# Without the last branch, local servers exposed on a non-993
|
||||
# port (Dovecot on 31143, etc.) would always fail the SSL
|
||||
# handshake because they're not actually wrapped in TLS.
|
||||
try:
|
||||
imap_kwargs = {
|
||||
"starttls": imap_starttls,
|
||||
"timeout": _IMAP_TIMEOUT_SECONDS,
|
||||
"owner": owner,
|
||||
}
|
||||
if google_ssl_context:
|
||||
imap_kwargs["ssl_context"] = google_ssl_context
|
||||
conn = _open_imap_connection(
|
||||
imap_host,
|
||||
imap_port,
|
||||
**imap_kwargs,
|
||||
)
|
||||
try:
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
|
||||
else:
|
||||
conn.login(imap_user, imap_pass)
|
||||
imap_result = {"ok": True}
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
imap_result = {"ok": False, "error": _mail_connection_test_error("IMAP", imap_host, e)}
|
||||
|
||||
smtp_host = (body.get("smtp_host") or "").strip()
|
||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||
if smtp_host and smtp_port_err:
|
||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
|
||||
elif (
|
||||
oauth_provider == "google"
|
||||
and smtp_host
|
||||
and not _google_oauth_smtp_transport_allowed(
|
||||
smtp_port,
|
||||
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
|
||||
)
|
||||
):
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
|
||||
elif smtp_host:
|
||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||
smtp_pass = body.get("smtp_password") or imap_pass
|
||||
smtp = None
|
||||
try:
|
||||
if smtp_security == "ssl":
|
||||
smtp_kwargs = (
|
||||
{"context": google_ssl_context}
|
||||
if google_ssl_context
|
||||
else {}
|
||||
)
|
||||
smtp = _PolicySMTP_SSL(
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
timeout=10,
|
||||
block_private=_mail_private_blocked(owner),
|
||||
**smtp_kwargs,
|
||||
)
|
||||
else:
|
||||
smtp = _PolicySMTP(smtp_host, smtp_port, timeout=10, block_private=_mail_private_blocked(owner))
|
||||
if smtp_security == "starttls":
|
||||
try:
|
||||
if google_ssl_context:
|
||||
smtp.starttls(context=google_ssl_context)
|
||||
else:
|
||||
smtp.starttls()
|
||||
except Exception:
|
||||
# STARTTLS failed before the auth cleanup block.
|
||||
# Close the still-open plaintext socket explicitly.
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
smtp = None
|
||||
raise
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
smtp.ehlo()
|
||||
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
|
||||
else:
|
||||
smtp.login(smtp_user, smtp_pass)
|
||||
smtp_result = {"ok": True}
|
||||
except Exception as e:
|
||||
smtp_result = {"ok": False, "error": _mail_connection_test_error("SMTP", smtp_host, e)}
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
||||
"imap": imap_result,
|
||||
"smtp": smtp_result,
|
||||
}
|
||||
|
||||
@router.post("/accounts/test")
|
||||
async def test_account_config(req: Request, owner: str = Depends(require_user)):
|
||||
"""Try to actually connect to the provided IMAP (and optionally SMTP)
|
||||
@@ -6923,148 +7117,7 @@ def setup_email_routes():
|
||||
# inline test payloads select the OAuth branch or supply token data.
|
||||
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
|
||||
|
||||
imap_result = {"ok": False}
|
||||
smtp_result = None
|
||||
|
||||
imap_host = (body.get("imap_host") or "").strip()
|
||||
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
||||
imap_user = (body.get("imap_user") or "").strip()
|
||||
imap_pass = body.get("imap_password") or ""
|
||||
imap_starttls = bool(body.get("imap_starttls"))
|
||||
oauth_provider = body.get("oauth_provider") or ""
|
||||
|
||||
google_token = None
|
||||
google_token_loaded = False
|
||||
google_ssl_context = (
|
||||
ssl.create_default_context()
|
||||
if oauth_provider == "google"
|
||||
else None
|
||||
)
|
||||
|
||||
def _google_token():
|
||||
nonlocal google_token, google_token_loaded
|
||||
if not google_token_loaded:
|
||||
google_token = _get_valid_google_token(body.get("account_id"), body)
|
||||
google_token_loaded = True
|
||||
if not google_token:
|
||||
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
||||
return google_token
|
||||
|
||||
if imap_port_err:
|
||||
imap_result = {"ok": False, "error": imap_port_err}
|
||||
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
|
||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
|
||||
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
|
||||
else:
|
||||
# Connection mode resolution:
|
||||
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
|
||||
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
|
||||
# STARTTLS off + any other port → plain IMAP4 (no encryption)
|
||||
# Without the last branch, local servers exposed on a non-993
|
||||
# port (Dovecot on 31143, etc.) would always fail the SSL
|
||||
# handshake because they're not actually wrapped in TLS.
|
||||
try:
|
||||
imap_kwargs = {
|
||||
"starttls": imap_starttls,
|
||||
"timeout": _IMAP_TIMEOUT_SECONDS,
|
||||
}
|
||||
if google_ssl_context:
|
||||
imap_kwargs["ssl_context"] = google_ssl_context
|
||||
conn = _open_imap_connection(
|
||||
imap_host,
|
||||
imap_port,
|
||||
**imap_kwargs,
|
||||
)
|
||||
try:
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
|
||||
else:
|
||||
conn.login(imap_user, imap_pass)
|
||||
imap_result = {"ok": True}
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
|
||||
|
||||
smtp_host = (body.get("smtp_host") or "").strip()
|
||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||
if smtp_host and smtp_port_err:
|
||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
|
||||
elif (
|
||||
oauth_provider == "google"
|
||||
and smtp_host
|
||||
and not _google_oauth_smtp_transport_allowed(
|
||||
smtp_port,
|
||||
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
|
||||
)
|
||||
):
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
|
||||
elif smtp_host:
|
||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||
smtp_pass = body.get("smtp_password") or imap_pass
|
||||
smtp = None
|
||||
try:
|
||||
if smtp_security == "ssl":
|
||||
smtp_kwargs = (
|
||||
{"context": google_ssl_context}
|
||||
if google_ssl_context
|
||||
else {}
|
||||
)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
timeout=10,
|
||||
**smtp_kwargs,
|
||||
)
|
||||
else:
|
||||
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
|
||||
if smtp_security == "starttls":
|
||||
try:
|
||||
if google_ssl_context:
|
||||
smtp.starttls(context=google_ssl_context)
|
||||
else:
|
||||
smtp.starttls()
|
||||
except Exception:
|
||||
# STARTTLS failed before the auth cleanup block.
|
||||
# Close the still-open plaintext socket explicitly.
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
smtp = None
|
||||
raise
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
smtp.ehlo()
|
||||
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
|
||||
else:
|
||||
smtp.login(smtp_user, smtp_pass)
|
||||
smtp_result = {"ok": True}
|
||||
except Exception as e:
|
||||
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
||||
"imap": imap_result,
|
||||
"smtp": smtp_result,
|
||||
}
|
||||
return await run_in_threadpool(_test_mail_connections, body, owner)
|
||||
|
||||
@router.post("/accounts/{account_id}/set-default")
|
||||
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
|
||||
|
||||
@@ -5,8 +5,9 @@ import shlex
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from core.middleware import require_admin
|
||||
from core.platform_compat import run_ssh_command
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
@@ -198,7 +199,11 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
|
||||
|
||||
|
||||
def setup_hwfit_routes():
|
||||
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
|
||||
# Admin-gated like the Cookbook routes this backs (/api/cookbook/gpus does
|
||||
# the same SSH host probing): `host`/`ssh_port` make the server SSH out with
|
||||
# its own key to any syntactically valid host, and `model_path` probes the
|
||||
# host filesystem. Single-user mode and internal tool calls still pass.
|
||||
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"], dependencies=[Depends(require_admin)])
|
||||
|
||||
@router.get("/system")
|
||||
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
|
||||
|
||||
@@ -43,11 +43,22 @@ def setup_search_routes(config) -> APIRouter:
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@router.get("/search/web", response_class=HTMLResponse)
|
||||
async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse:
|
||||
async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse:
|
||||
"""Browser-facing search results page for clickable agent web_search rows."""
|
||||
# The site CSP allows inline script only with the per-request nonce
|
||||
# (SecurityHeadersMiddleware); without it this page's script never ran.
|
||||
nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True)
|
||||
safe_q = str(q or "").strip()
|
||||
title = html.escape(safe_q or "Web search")
|
||||
q_json = json.dumps(safe_q)
|
||||
# json.dumps leaves `<`, `>` and `&` alone, so q=</script><script ...>
|
||||
# would close this inline <script> and inject markup (reflected XSS).
|
||||
# Unicode-escape them; the JS string value is unchanged.
|
||||
q_json = (
|
||||
json.dumps(safe_q)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -79,7 +90,7 @@ def setup_search_routes(config) -> APIRouter:
|
||||
<div class="status" id="status">Loading...</div>
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="{nonce}">
|
||||
const initialQuery = {q_json};
|
||||
const input = document.getElementById('query');
|
||||
const statusEl = document.getElementById('status');
|
||||
@@ -103,7 +114,9 @@ def setup_search_routes(config) -> APIRouter:
|
||||
}}
|
||||
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
|
||||
resultsEl.innerHTML = sources.map(s => {{
|
||||
const url = s.url || s.link || '';
|
||||
const rawUrl = String(s.url || s.link || '');
|
||||
// Result URLs come from third-party search results: only http(s) may become a link.
|
||||
const url = /^https?:[/][/]/i.test(rawUrl) ? rawUrl : '';
|
||||
const title = s.title || url || 'Untitled';
|
||||
const snippet = s.snippet || s.content || '';
|
||||
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -1601,7 +1601,13 @@ def setup_session_routes(
|
||||
raise HTTPException(404, "Session not found")
|
||||
skills = _context_info_skill_inventory(skills_manager, owner=owner)
|
||||
tools = _context_info_tool_inventory()
|
||||
agents_md = _context_info_agents_md_inventory(cwd)
|
||||
# `cwd` is caller-chosen. Walking it for AGENTS.md and reporting whether
|
||||
# it exists confirms paths on the host, which /api/workspace/browse and
|
||||
# /vet reserve for admins (or single-user mode); keep that boundary here.
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
_host_paths_visible = owner_is_admin_or_single_user(owner)
|
||||
agents_md = _context_info_agents_md_inventory(cwd) if _host_paths_visible else []
|
||||
# Workspace visibility: lets the TUI answer "can the backend actually
|
||||
# see this directory?" (mounted vs bridge-only) without probing.
|
||||
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
|
||||
@@ -1626,7 +1632,7 @@ def setup_session_routes(
|
||||
}
|
||||
_workspace = {
|
||||
"backend_path": _backend_cwd,
|
||||
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||
"exists_in_backend": _host_paths_visible and bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||
"mount_configured": bool(workspace_mount_pairs()),
|
||||
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.text_helpers import strip_closed_think_blocks
|
||||
from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -250,7 +251,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
# Strip closed think blocks. If a <think> was opened but never closed
|
||||
# (the model ran out of budget mid-reasoning), drop everything from it
|
||||
# onward so its stray braces don't poison JSON extraction.
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I)
|
||||
text = strip_closed_think_blocks(text)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
|
||||
def _coerce(d):
|
||||
@@ -403,7 +404,7 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
|
||||
except Exception as e:
|
||||
logger.warning(f"Necessity check failed: {e}")
|
||||
return None
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
data = None
|
||||
a, b = text.find('{'), text.rfind('}')
|
||||
@@ -493,7 +494,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||
except Exception as e:
|
||||
logger.warning(f"Retrieval precision check failed: {e}")
|
||||
return None
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
data = None
|
||||
a, b = text.find('{'), text.rfind('}')
|
||||
@@ -1188,7 +1189,7 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
|
||||
except Exception as e:
|
||||
logger.warning(f"Audit: improve call failed: {e}")
|
||||
return None
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
|
||||
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
|
||||
if text.startswith("```"):
|
||||
|
||||
@@ -1252,8 +1252,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if not out.get("prompt"):
|
||||
return {"success": False, "message": "Could not extract a task instruction"}
|
||||
return {"success": True, "draft": out}
|
||||
except HTTPException as e:
|
||||
# llm_core raises HTTPException with curated upstream text.
|
||||
logger.error(f"parse_task failed: {e.detail}")
|
||||
return {"success": False, "message": str(e.detail)}
|
||||
except Exception as e:
|
||||
# Anything else (JSON/parse/runtime errors) can carry internals;
|
||||
# log it and keep the client message generic.
|
||||
logger.error(f"parse_task failed: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
return {"success": False, "message": f"Could not draft a task ({type(e).__name__})"}
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user