mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 16:22:20 +01:00
Merge current dev and refresh PR #6738 from the 7011 integration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+25
-25
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
from src.settings import (
|
||||
@@ -129,6 +130,21 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
return auth_manager.get_username_for_token(token)
|
||||
|
||||
def _require_admin_user(request: Request) -> Optional[str]:
|
||||
"""Resolve the current user, enforcing admin access.
|
||||
|
||||
Honors AUTH_ENABLED=false the same way core.middleware.require_admin
|
||||
does: when the operator has explicitly disabled auth (e.g. behind a
|
||||
reverse proxy that handles login), there is no session user, and
|
||||
admin-gated app configuration must stay reachable.
|
||||
"""
|
||||
user = _get_current_user(request)
|
||||
if _auth_disabled():
|
||||
return user
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
@router.post("/setup")
|
||||
async def first_run_setup(body: SetupRequest, request: Request):
|
||||
"""Create initial admin account. Only works if no accounts exist."""
|
||||
@@ -718,9 +734,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.post("/features")
|
||||
async def set_features(request: Request):
|
||||
"""Admin only: update feature toggles."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
current = _load_features()
|
||||
for key in current:
|
||||
@@ -738,16 +752,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||
user = _get_current_user(request)
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if user and auth_manager.is_admin(user):
|
||||
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
|
||||
@router.post("/settings")
|
||||
async def set_settings(request: Request):
|
||||
"""Admin only: update app settings."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
current = _load_settings()
|
||||
# Per-key validation for numeric settings: coerce to int and clamp to a
|
||||
@@ -780,9 +792,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Infer the general prose style from one user-supplied document."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
user = _require_admin_user(request)
|
||||
filename = Path(file.filename or "sample.txt").name
|
||||
suffix = Path(filename).suffix.lower()
|
||||
allowed = {
|
||||
@@ -861,9 +871,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.get("/integrations")
|
||||
async def list_integrations_route(request: Request):
|
||||
"""List all integrations (admin only, keys masked)."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
items = load_integrations()
|
||||
# Mask API keys for frontend display
|
||||
safe = [mask_integration_secret(item) for item in items]
|
||||
@@ -877,9 +885,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.post("/integrations")
|
||||
async def create_integration(request: Request):
|
||||
"""Create a new integration (admin only)."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
@@ -887,9 +893,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.put("/integrations/{integration_id}")
|
||||
async def update_integration_route(integration_id: str, request: Request):
|
||||
"""Update an existing integration (admin only)."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
@@ -899,9 +903,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.delete("/integrations/{integration_id}")
|
||||
async def delete_integration_route(integration_id: str, request: Request):
|
||||
"""Delete an integration (admin only)."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
ok = delete_integration(integration_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
@@ -910,9 +912,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
@router.post("/integrations/{integration_id}/test")
|
||||
async def test_integration_route(integration_id: str, request: Request):
|
||||
"""Test connectivity to an integration (admin only)."""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
_require_admin_user(request)
|
||||
integ = get_integration(integration_id)
|
||||
if not integ:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
|
||||
@@ -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
|
||||
|
||||
+73
-3
@@ -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(
|
||||
@@ -549,6 +551,7 @@ async def auto_name_session(session_manager, sess):
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
# Find first user message
|
||||
first_msg = ""
|
||||
@@ -568,6 +571,50 @@ async def auto_name_session(session_manager, sess):
|
||||
|
||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||
model_name = str(getattr(sess, "model", "") or "")
|
||||
|
||||
# The spec ("Ajax") model names its own chats from the user's first
|
||||
# prompt. This function only runs after the first response stream has
|
||||
# settled (auto_name_session_after_stream), so titling does not
|
||||
# contend with the visible answer. Thinking is disabled and the
|
||||
# budget kept small so the model returns a bare title.
|
||||
try:
|
||||
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
|
||||
except Exception:
|
||||
_is_spec_model = False
|
||||
if _is_spec_model and endpoint_url:
|
||||
_headers = getattr(sess, "headers", None)
|
||||
if isinstance(_headers, str):
|
||||
try:
|
||||
_headers = json.loads(_headers)
|
||||
except Exception:
|
||||
_headers = None
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
endpoint_url,
|
||||
model_name,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=32,
|
||||
headers=_headers or None,
|
||||
timeout=20,
|
||||
thinking_mode="off",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
|
||||
title = ""
|
||||
title = str(title or "").strip().strip('"\'').strip()
|
||||
from src.text_helpers import strip_think
|
||||
title = strip_think(title, prose=False, prompt_echo=False)
|
||||
if not title or len(title) >= 80 or "\n" in title:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named spec session {sess.id}: {title}")
|
||||
return
|
||||
|
||||
if (
|
||||
"ttft" in model_name.lower()
|
||||
or re.search(r":18\d{3}\b", endpoint_url)
|
||||
@@ -601,6 +648,7 @@ async def auto_name_session(session_manager, sess):
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
|
||||
title = title.strip().strip('"\'').strip()
|
||||
@@ -1008,6 +1056,7 @@ async def build_chat_context(
|
||||
chat_processor,
|
||||
message: str,
|
||||
session_id: str,
|
||||
selected_notes_context: str | None = None,
|
||||
preset_id=None,
|
||||
att_ids: list = None,
|
||||
use_web=None,
|
||||
@@ -1180,6 +1229,23 @@ async def build_chat_context(
|
||||
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
||||
preface.append(untrusted_context_message("prefetched search context", search_context))
|
||||
|
||||
# Inject selected notes as context
|
||||
if selected_notes_context:
|
||||
selected_notes_context = (
|
||||
"The following selected notes and todos are reference context explicitly chosen "
|
||||
"by the user. Use them directly when relevant to the user's request. Do not "
|
||||
"ask for permission to use this context, and do not call note-management "
|
||||
"tools just to retrieve the same notes; use those tools only for a requested "
|
||||
"change or when this context is insufficient.\n\n"
|
||||
+ selected_notes_context
|
||||
)
|
||||
preface.append(
|
||||
untrusted_context_message(
|
||||
"Selected notes and todos",
|
||||
selected_notes_context,
|
||||
)
|
||||
)
|
||||
|
||||
# YouTube transcripts
|
||||
for transcript in preprocessed.youtube_transcripts:
|
||||
preface.append(untrusted_context_message("youtube transcript", transcript))
|
||||
@@ -1324,13 +1390,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 ',
|
||||
|
||||
+179
-47
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -27,6 +27,7 @@ from src.llm_core import (
|
||||
from src.agent_loop import (
|
||||
stream_agent_loop,
|
||||
_configured_model_tool_surface,
|
||||
_configured_model_tool_surface_raw,
|
||||
_local_media_needs_browser_render,
|
||||
_looks_like_workspace_coding_request,
|
||||
)
|
||||
@@ -49,11 +50,17 @@ from src.foreground_model_routing import (
|
||||
from src.session_search import search_session_messages
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
from src.auth_helpers import effective_user, get_current_user
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
get_current_user,
|
||||
is_delegated_credential,
|
||||
require_api_token_scope,
|
||||
require_chat_api_token_scope,
|
||||
)
|
||||
from routes.session_routes import _verify_session_owner
|
||||
from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
@@ -80,6 +87,8 @@ from src.tool_policy import (
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
from src.tool_approvals import tool_approval_store
|
||||
from src.tool_approval_scopes import stamp_chat_session_grant
|
||||
from src.tool_security import delegated_credential_blocked_tools
|
||||
from src.workspace_paths import backend_workspace_path
|
||||
from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES
|
||||
from src.model_profiles import (
|
||||
@@ -320,13 +329,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",
|
||||
@@ -1265,6 +1292,23 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _reject_delegated_tool_approval(request: Request) -> None:
|
||||
"""Refuse an approval answered by a bearer API token.
|
||||
|
||||
A tool approval records that a HUMAN authorized one dangerous action. A
|
||||
token is a delegated credential handed to an integration, so when it
|
||||
answers the prompt it triggered, nobody is asked and the gate collapses
|
||||
into an extra round trip. Owner and session already match here: the token
|
||||
is answering on behalf of the account that minted it.
|
||||
"""
|
||||
if is_delegated_credential(request):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Tool approvals require an interactive session. "
|
||||
"API tokens cannot authorize a gated action.",
|
||||
)
|
||||
|
||||
|
||||
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
"""Persist a consumed approval decision on its existing tool event."""
|
||||
|
||||
@@ -1289,6 +1333,11 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||
continue
|
||||
ask_user["resolved"] = normalized_decision
|
||||
stamp_chat_session_grant(
|
||||
ask_user,
|
||||
getattr(sess, "id", ""),
|
||||
normalized_decision,
|
||||
)
|
||||
message_id = metadata.get("_db_id")
|
||||
resolved_metadata = {
|
||||
key: value for key, value in metadata.items() if key != "_db_id"
|
||||
@@ -2106,6 +2155,68 @@ def _set_user_time_from_request(request: Request) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_selected_note_ids(raw) -> list[str]:
|
||||
"""Validate user-selected Note IDs before passing them to SQLAlchemy."""
|
||||
if raw is None or raw == "":
|
||||
return []
|
||||
if not isinstance(raw, str) or len(raw) > 8192:
|
||||
raise HTTPException(400, "Invalid selected_note_ids payload")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(400, "Invalid selected_note_ids JSON") from exc
|
||||
if (
|
||||
not isinstance(parsed, list)
|
||||
or len(parsed) > 50
|
||||
or any(
|
||||
not isinstance(note_id, str)
|
||||
or not note_id.strip()
|
||||
or len(note_id) > 128
|
||||
for note_id in parsed
|
||||
)
|
||||
):
|
||||
raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs")
|
||||
return list(dict.fromkeys(parsed))
|
||||
|
||||
|
||||
def _format_selected_notes_context(notes) -> str:
|
||||
"""Format selected Notes/Todos as reference context for the model."""
|
||||
sections = []
|
||||
|
||||
for index, note in enumerate(notes, start=1):
|
||||
lines = [
|
||||
f"--- Selected Note {index} ---",
|
||||
f"Title: {note.title or '(untitled)'}",
|
||||
f"Type: {note.note_type or 'note'}",
|
||||
]
|
||||
|
||||
if note.content:
|
||||
lines.extend([
|
||||
"Content:",
|
||||
str(note.content),
|
||||
])
|
||||
|
||||
if note.items:
|
||||
try:
|
||||
items = json.loads(note.items)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
items = []
|
||||
|
||||
if isinstance(items, list):
|
||||
lines.append("Items:")
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
status = "done" if item.get("done") else "pending"
|
||||
text = str(item.get("text") or "").strip()
|
||||
|
||||
if text:
|
||||
lines.append(f"- [{status}] {text}")
|
||||
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager):
|
||||
"""Use an explicit request override, then fall back to the active preset."""
|
||||
@@ -2131,13 +2242,17 @@ def setup_chat_routes(
|
||||
webhook_manager=None,
|
||||
skills_manager=None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(tags=["chat"])
|
||||
router = APIRouter(
|
||||
tags=["chat"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat (non-streaming)
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
|
||||
require_api_token_scope(request, "chat")
|
||||
_set_user_time_from_request(request)
|
||||
|
||||
message = chat_request.message
|
||||
@@ -2354,6 +2469,7 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat_stream")
|
||||
async def chat_stream(request: Request) -> StreamingResponse:
|
||||
require_api_token_scope(request, "chat")
|
||||
body = None
|
||||
try:
|
||||
if request.headers.get("content-type", "").startswith("application/json"):
|
||||
@@ -2371,6 +2487,8 @@ def setup_chat_routes(
|
||||
form_data = await request.form()
|
||||
message = form_data.get("message")
|
||||
session = form_data.get("session")
|
||||
selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids"))
|
||||
|
||||
attachments = form_data.get("attachments")
|
||||
use_web = form_data.get("use_web")
|
||||
use_research = form_data.get("use_research")
|
||||
@@ -2473,7 +2591,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)
|
||||
@@ -2580,8 +2698,6 @@ def setup_chat_routes(
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
editor_state = form_data.get("editor_state") or ""
|
||||
editor_screenshot = form_data.get("editor_screenshot") or ""
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
@@ -2693,7 +2809,24 @@ def setup_chat_routes(
|
||||
current_rejected=workspace_rejected,
|
||||
)
|
||||
owner = effective_user(request)
|
||||
selected_notes = []
|
||||
if selected_note_ids:
|
||||
notes_db = SessionLocal()
|
||||
try:
|
||||
selected_notes = (
|
||||
notes_db.query(Note)
|
||||
.filter(
|
||||
Note.id.in_(selected_note_ids),
|
||||
Note.owner == owner,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
notes_db.close()
|
||||
selected_notes_context = _format_selected_notes_context(selected_notes)
|
||||
|
||||
if tool_approval_id:
|
||||
_reject_delegated_tool_approval(request)
|
||||
from src.agent_runtime.authority import require_user_approval_request
|
||||
require_user_approval_request(request)
|
||||
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
||||
@@ -2963,6 +3096,7 @@ def setup_chat_routes(
|
||||
sess, request, chat_handler, chat_processor,
|
||||
message=message,
|
||||
session_id=session,
|
||||
selected_notes_context=selected_notes_context,
|
||||
preset_id=preset_id,
|
||||
att_ids=att_ids,
|
||||
use_web=use_web,
|
||||
@@ -3163,10 +3297,6 @@ def setup_chat_routes(
|
||||
)
|
||||
)
|
||||
if _photo_editor_active:
|
||||
# The editor tool family drives the browser-side image editor; it
|
||||
# replaces the generic edit_image instruction. Without an open
|
||||
# editor session the tools cannot reach the canvas, so tell the
|
||||
# user to open the image instead of emitting failing tool calls.
|
||||
if editor_session:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||
@@ -3236,6 +3366,12 @@ def setup_chat_routes(
|
||||
if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities:
|
||||
_explicit_web_intent = False
|
||||
disabled_tools = set()
|
||||
# Minting is admin-only, so every owner-keyed check below answers
|
||||
# "admin" for a token. Cap it at the non-admin policy instead.
|
||||
# stream_agent_loop repeats this from delegated_credential.
|
||||
_delegated_credential = is_delegated_credential(request)
|
||||
if _delegated_credential:
|
||||
disabled_tools.update(delegated_credential_blocked_tools())
|
||||
# Only disable bash when the caller *explicitly* set it to a falsy
|
||||
# value. When unset (None), defer to per-user privilege checks below.
|
||||
# Web search is per-turn opt-in: either the chat pre-search setting
|
||||
@@ -3478,7 +3614,7 @@ def setup_chat_routes(
|
||||
# The strict router (spec) surface picks its tools in src/spec_router.py
|
||||
# AFTER authority is built, so its admission is keyed off the surface.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface(
|
||||
_spec_surface = _configured_model_tool_surface_raw(
|
||||
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||
) == "spec"
|
||||
except Exception:
|
||||
@@ -3490,15 +3626,8 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
spec_surface=_spec_surface,
|
||||
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||
_explicit_browser_intent
|
||||
or _external_discovery_intent
|
||||
# Grant search/browser tools whenever the turn actually offers
|
||||
# them (Web toggle on / not denied), not only for explicit
|
||||
# browser wording. Otherwise the model sees web_search in its
|
||||
# schema but every call is rejected by request authority.
|
||||
or "web_search" not in disabled_tools
|
||||
_explicit_browser_intent or _external_discovery_intent
|
||||
) else set())),
|
||||
)
|
||||
if exact_tool_approval is not None:
|
||||
@@ -3525,6 +3654,8 @@ def setup_chat_routes(
|
||||
if s["function"]["name"] != "host_shell"]
|
||||
_contract_mgr = get_mcp_manager()
|
||||
_owner_blocked = blocked_tools_for_owner(_user)
|
||||
if _delegated_credential:
|
||||
_owner_blocked.update(delegated_credential_blocked_tools())
|
||||
if (
|
||||
_workspace_tools_disabled_for_owner(_user)
|
||||
and not _native_workspace_contract
|
||||
@@ -3723,17 +3854,10 @@ def setup_chat_routes(
|
||||
)
|
||||
# Every recovery path receives the same scope denial. The central
|
||||
# dispatcher also checks the immutable contract after rewrites.
|
||||
# The strict router (spec) surface picks its own tools AFTER this
|
||||
# contract is resolved, so scoping disabled_tools to the regex
|
||||
# contract would empty its menu (e.g. all email/notes tools denied
|
||||
# for a plain "search for the appointment email" turn). On spec
|
||||
# turns keep only the pre-contract denials (user/privilege/global/
|
||||
# plan/compare) and let src/spec_router.py + the spec surface decide.
|
||||
if not _spec_surface:
|
||||
disabled_tools.update(
|
||||
s["function"]["name"] for s in _contract_schemas
|
||||
if not _turn_contract.permits(s["function"]["name"])
|
||||
)
|
||||
disabled_tools.update(
|
||||
s["function"]["name"] for s in _contract_schemas
|
||||
if not _turn_contract.permits(s["function"]["name"])
|
||||
)
|
||||
# Contract resolution is the final policy-and-routing authority.
|
||||
# Some legacy/API-model paths arrive with a stale disabled snapshot
|
||||
# assembled before routing. The scope-denial pass above may retain
|
||||
@@ -4046,10 +4170,9 @@ def setup_chat_routes(
|
||||
owner=_user,
|
||||
size="auto",
|
||||
progress_callback=_image_progress_callback,
|
||||
incognito=incognito,
|
||||
))
|
||||
else:
|
||||
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user, incognito=incognito))
|
||||
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user))
|
||||
_img_started = time.time()
|
||||
_img_tick = 0
|
||||
while not _img_task.done():
|
||||
@@ -4161,8 +4284,6 @@ def setup_chat_routes(
|
||||
# indicator, but don't fold them into the saved
|
||||
# reply (mirrors the rewrite path below).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response += data["delta"]
|
||||
@@ -4588,6 +4709,7 @@ def setup_chat_routes(
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
delegated_credential=_delegated_credential,
|
||||
exact_approval=exact_tool_approval,
|
||||
client_runtime_context=client_runtime_context,
|
||||
thinking_mode=thinking_mode,
|
||||
@@ -4605,8 +4727,6 @@ def setup_chat_routes(
|
||||
# Forward them for the live indicator, but keep
|
||||
# them out of the saved reply (same as chat mode).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response = _render_state.content
|
||||
@@ -4790,6 +4910,25 @@ def setup_chat_routes(
|
||||
_terminal_saved = True
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, _saveable_no_tool_response,
|
||||
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
agent_rounds=_agent_rounds,
|
||||
agent_tool_calls=_agent_tool_calls,
|
||||
skills_manager=skills_manager,
|
||||
owner=_user,
|
||||
extract_skills=(
|
||||
user_requested_agent
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=_post_response_extraction_allowed(
|
||||
tools_blocked=tool_policy.block_all_tool_calls,
|
||||
tool_approval_continuation=tool_approval_continuation,
|
||||
client_runtime_context=client_runtime_context,
|
||||
),
|
||||
)
|
||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
@@ -4898,20 +5037,13 @@ def setup_chat_routes(
|
||||
async def _safe_stream() -> AsyncGenerator[str, None]:
|
||||
"""Wrapper that guarantees _active_streams cleanup even if stream_with_save
|
||||
raises before reaching a mode-specific finally block."""
|
||||
_gen = stream_with_save()
|
||||
try:
|
||||
while not _shutdown.is_shutting_down():
|
||||
try:
|
||||
chunk = await _gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
async for chunk in stream_with_save():
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
_active_streams.pop(session, None)
|
||||
try:
|
||||
await _gen.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compare panes and explicitly unattended native clients are
|
||||
# short-lived, single-shot generations with nobody to resume them.
|
||||
|
||||
@@ -35,6 +35,7 @@ CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"}
|
||||
CALENDAR_WRITE_SCOPES = {"calendar:write"}
|
||||
DOCS_READ_SCOPES = {"documents:read", "documents:write"}
|
||||
DOCS_WRITE_SCOPES = {"documents:write"}
|
||||
CHAT_READ_SCOPES = {"chat"}
|
||||
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||
|
||||
|
||||
@@ -161,6 +162,7 @@ def setup_codex_routes(
|
||||
memory_router: APIRouter | None = None,
|
||||
calendar_router: APIRouter | None = None,
|
||||
document_router: APIRouter | None = None,
|
||||
history_router: APIRouter | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/codex", tags=["codex"])
|
||||
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
|
||||
@@ -174,6 +176,7 @@ def setup_codex_routes(
|
||||
documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library")
|
||||
documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}")
|
||||
documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document")
|
||||
chat_history_endpoint = _find_endpoint(history_router, "GET", "/api/history/{session_id}")
|
||||
|
||||
@router.get("/capabilities")
|
||||
def capabilities(request: Request):
|
||||
@@ -219,6 +222,11 @@ def setup_codex_routes(
|
||||
"launch": scoped(COOKBOOK_LAUNCH_SCOPES),
|
||||
"actions": ["tasks", "servers", "output", "serve", "stop"],
|
||||
},
|
||||
"chat": {
|
||||
"read": scoped(CHAT_READ_SCOPES),
|
||||
"actions": ["read"],
|
||||
"available": chat_history_endpoint is not None,
|
||||
},
|
||||
},
|
||||
"safety": {
|
||||
"email_send_requires_confirmation": True,
|
||||
@@ -226,6 +234,33 @@ def setup_codex_routes(
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/chat/{session_id}")
|
||||
async def read_chat(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
_scope_owner(request, CHAT_READ_SCOPES)
|
||||
if chat_history_endpoint is None:
|
||||
raise HTTPException(503, "chat history unavailable")
|
||||
offset, limit = _clamp_pagination(
|
||||
offset,
|
||||
limit,
|
||||
default_limit=100,
|
||||
max_limit=100,
|
||||
)
|
||||
result = await chat_history_endpoint(request, session_id, limit, offset)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"name": result["name"],
|
||||
"model": result["model"],
|
||||
"messages": result["history"],
|
||||
"total": result["total"],
|
||||
"offset": result["offset"],
|
||||
"limit": result["limit"],
|
||||
}
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_authenticated_request(request)
|
||||
|
||||
@@ -701,13 +701,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return cmd
|
||||
|
||||
repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4"
|
||||
snapshot = (
|
||||
"/home/pewds/.cache/huggingface/hub/"
|
||||
"models--cyankiwi--MiniMax-M3-AWQ-INT4/"
|
||||
"snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b"
|
||||
)
|
||||
if body[serve_i + 1] == repo_id:
|
||||
body[serve_i + 1] = snapshot
|
||||
hf_home = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")))
|
||||
hf_cache = Path(os.environ.get("HUGGINGFACE_HUB_CACHE", str(hf_home / "hub")))
|
||||
snapshot_root = hf_cache / "models--cyankiwi--MiniMax-M3-AWQ-INT4" / "snapshots"
|
||||
if body[serve_i + 1] == repo_id and snapshot_root.is_dir():
|
||||
installed_snapshots = sorted(
|
||||
(p for p in snapshot_root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if installed_snapshots:
|
||||
body[serve_i + 1] = str(installed_snapshots[0])
|
||||
|
||||
def add_env(key: str, value: str) -> None:
|
||||
if not any(p.startswith(f"{key}=") for p in env_parts):
|
||||
|
||||
@@ -1185,7 +1185,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
||||
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
||||
Caches verdicts so previously-reviewed docs are skipped."""
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
@@ -1228,13 +1228,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
+ "\n".join(doc_list)
|
||||
)
|
||||
|
||||
response = await llm_call_async(
|
||||
url, model,
|
||||
response = await task_llm_call_async(
|
||||
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
||||
{"role": "user", "content": prompt}],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=user or None,
|
||||
temperature=0.1,
|
||||
max_tokens=200,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -443,9 +443,11 @@ async def _generate_email_summary(
|
||||
headers: dict | None = None,
|
||||
max_tokens: int = 8192,
|
||||
timeout: int = 180,
|
||||
owner: str | None = None,
|
||||
) -> str:
|
||||
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
raw = await llm_call_async(
|
||||
url=url,
|
||||
@@ -456,6 +458,7 @@ async def _generate_email_summary(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload="foreground",
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
return _normalize_email_summary(raw)
|
||||
|
||||
|
||||
@@ -1031,6 +1031,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=account_owner or None,
|
||||
temperature=0.7, max_tokens=1024, timeout=90,
|
||||
reasoning_effort="",
|
||||
)
|
||||
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
||||
if reply:
|
||||
|
||||
+628
-24
@@ -25,13 +25,16 @@ import json
|
||||
import re
|
||||
import html
|
||||
import io
|
||||
import tempfile
|
||||
import zipfile
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
from html.parser import HTMLParser as _HTMLParser
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from email.message import EmailMessage
|
||||
from email import policy as email_policy
|
||||
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
@@ -40,6 +43,7 @@ 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
|
||||
from src import shutdown as _shutdown
|
||||
from src.path_confinement import confine
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
@@ -81,6 +85,18 @@ _SERVER_OWNED_OAUTH_FIELDS = {
|
||||
"oauth_refresh_token",
|
||||
"oauth_token_expiry",
|
||||
}
|
||||
_CACHE_HOOKS: dict = {"invalidate_list": None}
|
||||
|
||||
|
||||
def _check_mail_host(host, owner: str, label: str) -> str | None:
|
||||
"""Reject unsafe or unresolved mail hosts before saving or testing them."""
|
||||
from src.url_safety import check_outbound_host
|
||||
ok, reason = check_outbound_host(host, block_private=_mail_private_blocked(owner))
|
||||
if ok:
|
||||
return None
|
||||
logger.warning("Rejected %s mail host for owner=%r: %s", label, owner, reason)
|
||||
# Resolver and exception details belong in server logs, never the response.
|
||||
return f"Rejected {label} host: server address is not allowed or does not resolve"
|
||||
|
||||
|
||||
def _normalized_mail_host(value) -> str:
|
||||
@@ -572,6 +588,23 @@ def _uid_exists(conn, uid: str, *, strict: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _uid_has_deleted_flag(conn, uid: str) -> bool:
|
||||
r"""Return True if message already carries \Deleted flag."""
|
||||
try:
|
||||
status, data = conn.uid("FETCH", _uid_bytes(uid), "(FLAGS)")
|
||||
if status == "OK" and data:
|
||||
for part in data:
|
||||
if not part:
|
||||
continue
|
||||
meta = part[0] if isinstance(part, tuple) else part
|
||||
meta_b = meta if isinstance(meta, bytes) else str(meta).encode()
|
||||
if rb"\Deleted" in meta_b:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_current_email_uid(conn, uid: str, message_id: str | None = None) -> str:
|
||||
"""Resolve a stale cached UID by the message's stable RFC Message-ID."""
|
||||
uid = str(uid or "").strip()
|
||||
@@ -618,10 +651,15 @@ def _message_id_chain(*values: str) -> list[str]:
|
||||
|
||||
|
||||
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b)
|
||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE)
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
|
||||
def _size_from_fetch_meta(meta_b: bytes) -> int | None:
|
||||
m = re.search(rb"\bRFC822\.SIZE\s+(\d+)\b", meta_b, re.IGNORECASE)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
|
||||
|
||||
@@ -1797,13 +1835,55 @@ def setup_email_routes():
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
_LIST_STALE = {}
|
||||
|
||||
def _list_stale_get(key):
|
||||
return _LIST_STALE.get(key)
|
||||
|
||||
def _list_stale_put(key, value):
|
||||
_LIST_STALE[key] = value
|
||||
if len(_LIST_STALE) > 96:
|
||||
for k in list(_LIST_STALE.keys())[:-48]:
|
||||
_LIST_STALE.pop(k, None)
|
||||
|
||||
_LIST_REFRESHING = set()
|
||||
|
||||
def _list_cache_put(key, value):
|
||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||
# Also snapshot the last-good response for stale-while-revalidate.
|
||||
_list_stale_put(key, value)
|
||||
# Cap size
|
||||
if len(_LIST_CACHE) > 64:
|
||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||
_LIST_CACHE.pop(k, None)
|
||||
|
||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||
|
||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||
the TTL cache and the stale snapshot so the next list view is warm.
|
||||
"""
|
||||
if cache_key in _LIST_REFRESHING:
|
||||
return
|
||||
_LIST_REFRESHING.add(cache_key)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
refreshed = _list_emails_sync(*sync_args)
|
||||
if refreshed and not refreshed.get("error"):
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
try:
|
||||
_schedule_recent_email_warm(
|
||||
refreshed.get("emails") or [], folder, account_id, owner)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - background best effort
|
||||
logger.debug("email list background refresh failed: %s", exc)
|
||||
finally:
|
||||
_LIST_REFRESHING.discard(cache_key)
|
||||
|
||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||
|
||||
def _folder_cache_get(account_id, owner):
|
||||
key = (account_id or "", owner or "")
|
||||
v = _FOLDER_CACHE.get(key)
|
||||
@@ -1831,6 +1911,12 @@ def setup_email_routes():
|
||||
delete, move) so the UI doesn't show stale read/unread counts for up to
|
||||
the 8s TTL after a manual flag change. With no args, clears everything.
|
||||
"""
|
||||
hook = _CACHE_HOOKS.get("invalidate_list")
|
||||
if hook:
|
||||
try:
|
||||
hook(account_id, folder)
|
||||
except Exception:
|
||||
pass
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
return
|
||||
@@ -2824,6 +2910,46 @@ def setup_email_routes():
|
||||
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
||||
)
|
||||
return cached
|
||||
# Stale-while-revalidate: never block a plain list view on IMAP while
|
||||
# we already hold a recent snapshot or a durable index. Serve it now
|
||||
# with a `stale` marker and refresh in the background so the next view
|
||||
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||
if not from_addr and not has_attachments and not (date_from or date_to):
|
||||
snapshot = _list_stale_get(ck)
|
||||
if snapshot is None:
|
||||
try:
|
||||
indexed_emails, indexed_total, indexed_at = _email_index_list(
|
||||
owner, account_id, folder, filter, limit, offset, False,
|
||||
)
|
||||
except Exception:
|
||||
indexed_emails, indexed_total, indexed_at = [], 0, None
|
||||
if indexed_total:
|
||||
snapshot = {
|
||||
"emails": indexed_emails,
|
||||
"total": indexed_total,
|
||||
"folder": folder,
|
||||
"offset": offset,
|
||||
"sync": {
|
||||
"source": "index",
|
||||
"indexed": indexed_total,
|
||||
"updated_at": indexed_at,
|
||||
},
|
||||
}
|
||||
if snapshot is not None:
|
||||
payload = dict(snapshot)
|
||||
payload["emails"] = list(payload.get("emails") or [])
|
||||
_hide_unlinked_calendar_tags(payload["emails"])
|
||||
sync_meta = dict(payload.get("sync") or {})
|
||||
sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index"
|
||||
sync_meta["stale"] = True
|
||||
payload["sync"] = sync_meta
|
||||
_schedule_list_refresh(
|
||||
(folder, limit, offset, filter, account_id, from_addr,
|
||||
bool(has_attachments), owner, True, date_from or "", date_to or ""),
|
||||
ck, folder, account_id, owner,
|
||||
)
|
||||
return payload
|
||||
result = await _asyncio.to_thread(
|
||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||
@@ -4161,6 +4287,414 @@ def setup_email_routes():
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
|
||||
)
|
||||
|
||||
@router.post("/messages-download-bulk")
|
||||
async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)):
|
||||
"""Export selected original email messages as .eml files in a ZIP."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
messages = payload.get("messages") if isinstance(payload, dict) else None
|
||||
if not isinstance(messages, list) or not messages:
|
||||
raise HTTPException(status_code=400, detail="No emails selected")
|
||||
if len(messages) > 250:
|
||||
raise HTTPException(status_code=400, detail="Too many emails selected")
|
||||
|
||||
category = str(payload.get("category") or "selected").strip().lower()
|
||||
category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected"
|
||||
selected_dates = sorted({
|
||||
str(row.get("date") or "").strip()
|
||||
for row in messages if isinstance(row, dict)
|
||||
and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip())
|
||||
})
|
||||
date_span = ""
|
||||
if selected_dates:
|
||||
date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}"
|
||||
export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "")
|
||||
|
||||
# 512 MiB safety cap prevents unbounded memory growth when exporting
|
||||
# many large messages.
|
||||
max_total_bytes = 512 * 1024 * 1024
|
||||
max_message_bytes = 64 * 1024 * 1024
|
||||
spool_threshold_bytes = 32 * 1024 * 1024
|
||||
used_names: set[str] = set()
|
||||
exported = 0
|
||||
skipped = 0
|
||||
total_bytes = 0
|
||||
validated_accounts: set[str] = set()
|
||||
|
||||
def _email_filename(row: dict, uid: str) -> str:
|
||||
date = str(row.get("date") or "").strip()
|
||||
date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else ""
|
||||
subject = str(row.get("subject") or "email").strip()
|
||||
subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject)
|
||||
subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email"
|
||||
safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message"
|
||||
prefix = f"{date} - " if date else ""
|
||||
return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml")
|
||||
|
||||
def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes:
|
||||
msg = EmailMessage()
|
||||
sender_name = str(fixture.get("from_name") or "").strip()
|
||||
sender_address = str(fixture.get("from_address") or "").strip()
|
||||
sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name
|
||||
for key, value in (
|
||||
("From", sender),
|
||||
("To", fixture.get("to") or owner),
|
||||
("Subject", fixture.get("subject") or "(no subject)"),
|
||||
("Message-ID", fixture.get("message_id") or f"<fixture-{uid}@mail.local>"),
|
||||
):
|
||||
clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip()
|
||||
if clean_value:
|
||||
msg[key] = clean_value
|
||||
raw_date = str(fixture.get("date") or "").strip()
|
||||
if raw_date:
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
|
||||
if parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
try:
|
||||
parsed_date = email.utils.parsedate_to_datetime(raw_date)
|
||||
except Exception:
|
||||
parsed_date = None
|
||||
if parsed_date is not None:
|
||||
msg["Date"] = email.utils.format_datetime(parsed_date)
|
||||
msg.set_content(str(fixture.get("body") or ""))
|
||||
for attachment in fixture.get("attachments") or []:
|
||||
try:
|
||||
index = int(attachment.get("index"))
|
||||
source = _fixture_attachment_source(uid, index, owner, folder)
|
||||
if not source:
|
||||
continue
|
||||
_row, raw_attachment = source
|
||||
content_type = str(raw_attachment.get("content_type") or "application/octet-stream")
|
||||
maintype, _, subtype = content_type.partition("/")
|
||||
if not subtype:
|
||||
maintype, subtype = "application", "octet-stream"
|
||||
msg.add_attachment(
|
||||
str(raw_attachment.get("content") or "").encode("utf-8"),
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True)
|
||||
return msg.as_bytes(policy=email_policy.SMTP)
|
||||
|
||||
# Optimization: single message exports return raw .eml directly without ZIP compression.
|
||||
if len(messages) == 1:
|
||||
row = messages[0]
|
||||
if not isinstance(row, dict):
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
uid = str(row.get("uid") or "").strip()
|
||||
if not uid:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = str(row.get("account_id") or row.get("account") or "").strip() or None
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
filename = _email_filename(row, uid)
|
||||
message_bytes: bytes | None = None
|
||||
|
||||
fixture = _fixture_email_read(uid, folder, owner)
|
||||
if fixture is not None and not fixture.get("error"):
|
||||
message_bytes = _fixture_message_bytes(fixture, uid, folder)
|
||||
elif re.fullmatch(r"\d+", uid):
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
uid_b = uid.encode()
|
||||
st_sz, sz_data = None, None
|
||||
try:
|
||||
st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)")
|
||||
except Exception as sz_err:
|
||||
logger.warning("Single message size preflight query error for uid=%s: %s", uid, sz_err)
|
||||
|
||||
if st_sz != "OK":
|
||||
logger.warning("Single message size preflight status=%s for uid=%s", st_sz, uid)
|
||||
raise HTTPException(status_code=502, detail="Unable to verify email size safety")
|
||||
|
||||
if not sz_data:
|
||||
# Message does not exist in mailbox (missing mail)
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
sz: int | None = None
|
||||
for item in sz_data:
|
||||
meta = item[0] if isinstance(item, tuple) else item
|
||||
if isinstance(meta, bytes):
|
||||
u = _uid_from_fetch_meta(meta)
|
||||
if u == uid:
|
||||
sz = _size_from_fetch_meta(meta)
|
||||
break
|
||||
|
||||
if sz is None or sz < 0:
|
||||
logger.warning("Single message size preflight returned invalid/malformed size for uid=%s", uid)
|
||||
raise HTTPException(status_code=502, detail="Invalid email size metadata from server")
|
||||
|
||||
if sz > max_message_bytes or sz > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
# Preflight size safety established; perform RFC822 fetch.
|
||||
st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)")
|
||||
if st == "OK" and msg_data:
|
||||
for meta, raw in _group_uid_fetch_records(msg_data):
|
||||
if _uid_from_fetch_meta(meta) == uid and raw:
|
||||
message_bytes = bytes(raw)
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Single email export failed for uid=%s folder=%s: %s", uid, folder, exc)
|
||||
|
||||
if not message_bytes:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
if len(message_bytes) > max_message_bytes or len(message_bytes) > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(message_bytes),
|
||||
media_type="message/rfc822",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}",
|
||||
"X-Exported-Emails": "1",
|
||||
"X-Skipped-Emails": "0",
|
||||
},
|
||||
)
|
||||
|
||||
seen_items: set[tuple[str | None, str, str]] = set()
|
||||
remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {}
|
||||
spooled_file = tempfile.SpooledTemporaryFile(max_size=spool_threshold_bytes, mode="w+b")
|
||||
response_started = False
|
||||
try:
|
||||
with zipfile.ZipFile(spooled_file, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for row in messages:
|
||||
if not isinstance(row, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
uid = str(row.get("uid") or "").strip()
|
||||
if not uid:
|
||||
skipped += 1
|
||||
continue
|
||||
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = str(row.get("account_id") or row.get("account") or "").strip() or None
|
||||
if account_id and account_id not in validated_accounts:
|
||||
_assert_owns_account(account_id, owner)
|
||||
validated_accounts.add(account_id)
|
||||
|
||||
item_key = (account_id, folder, uid)
|
||||
if item_key in seen_items:
|
||||
skipped += 1
|
||||
continue
|
||||
seen_items.add(item_key)
|
||||
|
||||
fixture = _fixture_email_read(uid, folder, owner)
|
||||
if fixture is not None and not fixture.get("error"):
|
||||
filename = _email_filename(row, uid)
|
||||
unique_name = filename
|
||||
suffix = 2
|
||||
while unique_name.lower() in used_names:
|
||||
unique_name = f"{Path(filename).stem}-{suffix}.eml"
|
||||
suffix += 1
|
||||
used_names.add(unique_name.lower())
|
||||
message_bytes = _fixture_message_bytes(fixture, uid, folder)
|
||||
if len(message_bytes) > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
total_bytes += len(message_bytes)
|
||||
if total_bytes > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
try:
|
||||
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
|
||||
except Exception as zip_err:
|
||||
logger.error("ZIP entry write failed for fixture: %s", zip_err)
|
||||
raise HTTPException(status_code=500, detail="Failed to create export archive")
|
||||
exported += 1
|
||||
continue
|
||||
if not re.fullmatch(r"\d+", uid):
|
||||
skipped += 1
|
||||
continue
|
||||
group = remote_groups.setdefault((account_id, folder), {})
|
||||
group.setdefault(uid, (_email_filename(row, uid), row))
|
||||
|
||||
preflight_error: HTTPException | None = None
|
||||
|
||||
# Fetch original RFC822 data in batches and store it byte-for-byte.
|
||||
# Note on IMAP memory-safety: Python's standard imaplib buffers socket responses into
|
||||
# memory before returning literals to application code. Preflight verification ensures
|
||||
# we do not intentionally fetch messages exceeding limits, but an untrusted or non-compliant
|
||||
# IMAP server sending unexpectedly large literals cannot be bounded at the transport level
|
||||
# without custom socket streaming. Post-fetch checks guard application-level storage.
|
||||
for (account_id, folder), rows_by_uid in remote_groups.items():
|
||||
rows = list(rows_by_uid.items())
|
||||
remaining_rows = len(rows)
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
|
||||
# Size preflight query to catch oversized exports early and bound batch sizes.
|
||||
uid_set_all = ",".join(uid for uid, _ in rows).encode()
|
||||
st_sz, sz_data = None, None
|
||||
try:
|
||||
st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)")
|
||||
except Exception as sz_err:
|
||||
logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err)
|
||||
|
||||
if st_sz != "OK":
|
||||
logger.warning("IMAP size preflight status=%s for folder=%s account=%s", st_sz, folder, account_id)
|
||||
preflight_error = HTTPException(status_code=502, detail="Unable to verify email size safety")
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
if not sz_data:
|
||||
# All requested UIDs in this mailbox are missing on server
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
reported_sizes: dict[str, int | None] = {}
|
||||
for item in sz_data:
|
||||
meta = item[0] if isinstance(item, tuple) else item
|
||||
if isinstance(meta, bytes):
|
||||
u = _uid_from_fetch_meta(meta)
|
||||
if u:
|
||||
reported_sizes[u] = _size_from_fetch_meta(meta)
|
||||
|
||||
# Categorize requested UIDs
|
||||
malformed_uids = [
|
||||
uid for uid, _ in rows
|
||||
if uid in reported_sizes and (reported_sizes[uid] is None or reported_sizes[uid] < 0)
|
||||
]
|
||||
if malformed_uids:
|
||||
logger.warning("IMAP size preflight returned invalid/malformed size for uids=%s in folder=%s", malformed_uids, folder)
|
||||
preflight_error = HTTPException(status_code=502, detail="Invalid email size metadata from server")
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
# Identify missing mail (requested UIDs not returned in preflight)
|
||||
missing_uids = {uid for uid, _ in rows if uid not in reported_sizes}
|
||||
if missing_uids:
|
||||
skipped += len(missing_uids)
|
||||
remaining_rows -= len(missing_uids)
|
||||
rows = [item for item in rows if item[0] not in missing_uids]
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
size_by_uid: dict[str, int] = {
|
||||
uid: reported_sizes[uid] for uid, _ in rows
|
||||
}
|
||||
|
||||
for u, s in size_by_uid.items():
|
||||
if s > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
if total_bytes + sum(size_by_uid.values()) > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
# Form batches with at most 20 messages and capped at 10 MiB payload.
|
||||
batches: list[list[tuple[str, tuple[str, dict]]]] = []
|
||||
cur_batch: list[tuple[str, tuple[str, dict]]] = []
|
||||
cur_batch_bytes = 0
|
||||
max_batch_bytes = 10 * 1024 * 1024
|
||||
for item in rows:
|
||||
uid_val = item[0]
|
||||
sz = size_by_uid[uid_val]
|
||||
if cur_batch and (
|
||||
len(cur_batch) >= 20 or
|
||||
(cur_batch_bytes + sz > max_batch_bytes)
|
||||
):
|
||||
batches.append(cur_batch)
|
||||
cur_batch = []
|
||||
cur_batch_bytes = 0
|
||||
cur_batch.append(item)
|
||||
cur_batch_bytes += sz
|
||||
if cur_batch:
|
||||
batches.append(cur_batch)
|
||||
|
||||
for batch in batches:
|
||||
wanted = {uid: filename for uid, (filename, _row) in batch}
|
||||
uid_set = ",".join(wanted).encode()
|
||||
status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)")
|
||||
if status != "OK" or not msg_data:
|
||||
skipped += len(batch)
|
||||
remaining_rows -= len(batch)
|
||||
continue
|
||||
found: set[str] = set()
|
||||
for meta, raw_message in _group_uid_fetch_records(msg_data):
|
||||
uid = _uid_from_fetch_meta(meta)
|
||||
if not uid or uid not in wanted or not raw_message:
|
||||
continue
|
||||
if uid in found:
|
||||
# P2: Reject duplicate IMAP response records for an already-processed UID
|
||||
logger.debug("Ignoring duplicate IMAP FETCH record for uid=%s in folder=%s", uid, folder)
|
||||
continue
|
||||
filename = wanted[uid]
|
||||
unique_name = filename
|
||||
suffix = 2
|
||||
while unique_name.lower() in used_names:
|
||||
unique_name = f"{Path(filename).stem}-{suffix}.eml"
|
||||
suffix += 1
|
||||
used_names.add(unique_name.lower())
|
||||
message_bytes = bytes(raw_message)
|
||||
if len(message_bytes) > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
total_bytes += len(message_bytes)
|
||||
if total_bytes > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
try:
|
||||
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
|
||||
except Exception as zip_err:
|
||||
logger.error("ZIP entry write failed for remote message: %s", zip_err)
|
||||
raise HTTPException(status_code=500, detail="Failed to create export archive")
|
||||
found.add(uid)
|
||||
exported += 1
|
||||
remaining_rows -= 1
|
||||
missing = len(batch) - len(found)
|
||||
skipped += len(batch) - len(found)
|
||||
remaining_rows -= missing
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
skipped += remaining_rows
|
||||
logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc)
|
||||
|
||||
if preflight_error is not None:
|
||||
raise preflight_error
|
||||
|
||||
if exported <= 0:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
spooled_file.seek(0)
|
||||
zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip")
|
||||
logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped)
|
||||
|
||||
def _iter_spooled(f, chunk_size=64 * 1024):
|
||||
try:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
response_started = True
|
||||
return StreamingResponse(
|
||||
_iter_spooled(spooled_file),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{zip_name}"',
|
||||
"X-Exported-Emails": str(exported),
|
||||
"X-Skipped-Emails": str(skipped),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not response_started:
|
||||
spooled_file.close()
|
||||
|
||||
@router.get("/inline-image/{uid}")
|
||||
async def inline_image(
|
||||
uid: str,
|
||||
@@ -4764,6 +5298,7 @@ def setup_email_routes():
|
||||
return {"success": False, "error": "No email UIDs provided", "deleted_uids": [], "failed_uids": []}
|
||||
deleted_uids = []
|
||||
failed_uids = []
|
||||
trash_folder = None
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
select_status, _ = conn.select(_q(folder), readonly=False)
|
||||
@@ -4779,7 +5314,8 @@ def setup_email_routes():
|
||||
trash_folder = "Trash"
|
||||
except Exception:
|
||||
pass
|
||||
pending_expunge = False
|
||||
pending_expunge = []
|
||||
preexisting_deleted = set()
|
||||
for uid in uids:
|
||||
try:
|
||||
if not _uid_exists(conn, uid):
|
||||
@@ -4789,32 +5325,75 @@ def setup_email_routes():
|
||||
if status != "OK":
|
||||
copy_status, _ = conn.uid("COPY", _uid_bytes(uid), _q(trash_folder))
|
||||
if copy_status != "OK":
|
||||
# Keep deletion reliable even when this IMAP
|
||||
# server has no writable Trash folder.
|
||||
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
||||
if store_status != "OK":
|
||||
failed_uids.append(uid)
|
||||
continue
|
||||
pending_expunge = True
|
||||
else:
|
||||
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
||||
if store_status != "OK":
|
||||
failed_uids.append(uid)
|
||||
continue
|
||||
pending_expunge = True
|
||||
# Bulk Delete means move to Trash. Never remove
|
||||
# the original without a recoverable Trash copy.
|
||||
failed_uids.append(uid)
|
||||
continue
|
||||
if _uid_has_deleted_flag(conn, uid):
|
||||
preexisting_deleted.add(uid)
|
||||
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
||||
if store_status != "OK":
|
||||
failed_uids.append(uid)
|
||||
continue
|
||||
# Keep the local index until the server confirms
|
||||
# that the copied originals have been removed.
|
||||
pending_expunge.append(uid)
|
||||
continue
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
except Exception:
|
||||
failed_uids.append(uid)
|
||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||
if pending_expunge:
|
||||
conn.expunge()
|
||||
if deleted_uids:
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids}
|
||||
expunge_ok = False
|
||||
# Attempt selective RFC 4315 UID EXPUNGE so unrelated messages
|
||||
# carrying \Deleted in the selected mailbox are never expunged.
|
||||
# Mailbox-wide EXPUNGE is strictly avoided to eliminate TOCTOU races
|
||||
# where concurrent clients mark unrelated messages \Deleted.
|
||||
try:
|
||||
uid_set_bytes = _uid_bytes(",".join(pending_expunge))
|
||||
uid_exp_status, _ = conn.uid("EXPUNGE", uid_set_bytes)
|
||||
if uid_exp_status == "OK":
|
||||
expunge_ok = True
|
||||
except Exception:
|
||||
logger.debug("Selective UID EXPUNGE failed", exc_info=True)
|
||||
expunge_ok = False
|
||||
|
||||
if expunge_ok:
|
||||
for uid in pending_expunge:
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||
# copied messages so future EXPUNGE operations do not destroy them.
|
||||
# Do not remove a pre-existing \Deleted flag present before this operation.
|
||||
for uid in pending_expunge:
|
||||
if uid not in preexisting_deleted:
|
||||
try:
|
||||
conn.uid("STORE", _uid_bytes(uid), "-FLAGS", "\\Deleted")
|
||||
except Exception:
|
||||
pass
|
||||
failed_uids.extend(pending_expunge)
|
||||
if not deleted_uids:
|
||||
return {"success": False, "error": "Failed to delete requested emails", "deleted_uids": [], "failed_uids": failed_uids}
|
||||
res = {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids}
|
||||
if failed_uids:
|
||||
res["error"] = "Mail operation partially failed"
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to bulk delete emails: {e}")
|
||||
return {"success": False, "error": "Mail operation failed", "deleted_uids": deleted_uids, "failed_uids": [u for u in uids if u not in deleted_uids]}
|
||||
failed_all = [u for u in uids if u not in deleted_uids]
|
||||
return {
|
||||
"success": bool(deleted_uids),
|
||||
"error": "Mail operation failed" if not deleted_uids else "Mail operation partially failed",
|
||||
"deleted_uids": deleted_uids,
|
||||
"failed_uids": failed_all,
|
||||
}
|
||||
finally:
|
||||
if deleted_uids:
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
if trash_folder:
|
||||
_invalidate_list_cache(account_id, trash_folder)
|
||||
|
||||
@router.delete("/odysseus/reminders")
|
||||
async def delete_odysseus_reminder_emails(
|
||||
@@ -5920,7 +6499,12 @@ def setup_email_routes():
|
||||
},
|
||||
]
|
||||
|
||||
style = await llm_call_async(url, model, messages, headers=headers, max_tokens=2048)
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
style = await llm_call_async(
|
||||
url, model, messages, headers=headers, max_tokens=2048,
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
style = _strip_think(style or "")
|
||||
if not style:
|
||||
return {"success": False, "error": "LLM failed to generate style description"}
|
||||
@@ -6004,6 +6588,7 @@ def setup_email_routes():
|
||||
headers=req_headers,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
owner=owner,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -6125,6 +6710,8 @@ def setup_email_routes():
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
content = await llm_call_async_with_fallback(
|
||||
candidates,
|
||||
messages=[
|
||||
@@ -6152,6 +6739,7 @@ def setup_email_routes():
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
candidate_request_factory=candidate_effort_factory(owner),
|
||||
)
|
||||
model = candidates[0][1] if candidates else ""
|
||||
content = (content or "").strip()
|
||||
@@ -6212,7 +6800,7 @@ def setup_email_routes():
|
||||
await queue.put({'type': 'result', **result})
|
||||
task = asyncio.create_task(run())
|
||||
try:
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
item = await queue.get()
|
||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||
if item.get('type') == 'result':
|
||||
@@ -6645,6 +7233,12 @@ def setup_email_routes():
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
|
||||
# Non-reply automation flags stay global. Away/auto-reply settings are
|
||||
# account-scoped when an account_id is supplied by the Email Settings UI.
|
||||
settings = _load_settings()
|
||||
@@ -6785,6 +7379,11 @@ def setup_email_routes():
|
||||
smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
@@ -6830,6 +7429,11 @@ def setup_email_routes():
|
||||
"""Update an email account. Passwords only overwrite if non-empty."""
|
||||
# Path param account_id — dep validated via Query, re-check the path-param value.
|
||||
_assert_owns_account(account_id, owner)
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,14 @@ import logging
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.auth_helpers import effective_user
|
||||
from src.auth_helpers import effective_user, require_chat_api_token_scope
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
@@ -236,8 +237,23 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int |
|
||||
return None
|
||||
|
||||
|
||||
def _effort_mode(model: str, raw_effort: Any) -> str:
|
||||
"""Session ``thinking_mode`` for a picked effort: ``effort:<level>`` or ``off``.
|
||||
|
||||
Validated like the chat routes do, so llama-swap advertised efforts persist
|
||||
as well as ChatGPT subscription levels.
|
||||
"""
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
effort = validate_reasoning_effort(model, raw_effort)
|
||||
return f"effort:{effort}" if effort else "off"
|
||||
|
||||
|
||||
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
router = APIRouter(tags=["history"])
|
||||
router = APIRouter(
|
||||
tags=["history"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
|
||||
def _reserve_message_uploads(
|
||||
request: Request,
|
||||
@@ -387,26 +403,53 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = int(body.get("keep_count", 0))
|
||||
before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip()
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Request body must be valid JSON")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
# Resolve before_msg_id / message_id alias with explicit presence checks.
|
||||
# None (JSON null) and empty string are both treated as "not provided here"
|
||||
# and fall through to the alias, preserving the original OR-chain semantics.
|
||||
# Any other non-string value (False, 0, True, 123, list, …) is rejected
|
||||
# immediately so falsy non-strings cannot bypass type validation.
|
||||
raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None
|
||||
if raw_msg_id is None or raw_msg_id == "":
|
||||
raw_msg_id = body.get("message_id")
|
||||
if raw_msg_id is not None and not isinstance(raw_msg_id, str):
|
||||
raise HTTPException(400, "Message ID must be a string")
|
||||
before_msg_id = (raw_msg_id or "").strip()
|
||||
if "keep_count" not in body and not before_msg_id:
|
||||
raise HTTPException(400, "keep_count or before_msg_id required")
|
||||
raw_count = body.get("keep_count", 0)
|
||||
# Keep integer-string clients working, but do not silently convert
|
||||
# booleans or fractional numbers into destructive message counts.
|
||||
if type(raw_count) not in (int, str):
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
try:
|
||||
keep_count = int(raw_count)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
if keep_count < 0:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
deleted_sft_pairs: list[dict[str, str]] = []
|
||||
if keep_count >= 0:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
# keep_count is always >= 0 here; the block is unconditional.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
result = session_manager.truncate_messages(session_id, keep_count)
|
||||
_remove_deleted_sft_trace_rows(
|
||||
owner=effective_user(request),
|
||||
@@ -432,7 +475,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
content = body.get("content", "")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
metadata = body.get("metadata")
|
||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||
_reserve_message_uploads(request, content, metadata)
|
||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||
session_manager.add_message(session_id, msg)
|
||||
@@ -1027,26 +1070,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
mode = getattr(session, "thinking_mode", "off") or "off"
|
||||
raw_effort = body.get("reasoning_effort")
|
||||
if raw_effort is not None:
|
||||
clean_effort = str(raw_effort).strip().lower()
|
||||
if clean_effort in {"", "default"}:
|
||||
mode = "off"
|
||||
else:
|
||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
||||
meta = get_chatgpt_model_metadata(session.model)
|
||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
||||
mode = f"effort:{clean_effort}"
|
||||
else:
|
||||
mode = "off"
|
||||
mode = _effort_mode(session.model, raw_effort)
|
||||
elif "thinking_mode" in body:
|
||||
raw_mode = str(body.get("thinking_mode") or "").strip().lower()
|
||||
if raw_mode.startswith("effort:"):
|
||||
clean_effort = raw_mode[7:].strip()
|
||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
||||
meta = get_chatgpt_model_metadata(session.model)
|
||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
||||
mode = f"effort:{clean_effort}"
|
||||
else:
|
||||
mode = "off"
|
||||
mode = _effort_mode(session.model, raw_mode[7:])
|
||||
elif raw_mode in {"", "on", "off"}:
|
||||
mode = raw_mode
|
||||
from src.model_profiles import supports_user_thinking_toggle
|
||||
@@ -1125,7 +1153,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
|
||||
# Use utility model if available
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
from src.utility_effort import effort_for_call
|
||||
util_url, util_model, util_headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=getattr(session, "endpoint_url", None),
|
||||
fallback_model=getattr(session, "model", None),
|
||||
fallback_headers=getattr(session, "headers", None),
|
||||
owner=owner or None,
|
||||
)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
@@ -1141,6 +1176,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
],
|
||||
temperature=0.2, max_tokens=1024,
|
||||
headers=compact_headers, timeout=30,
|
||||
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||
)
|
||||
summary = normalize_compaction_summary(summary)
|
||||
|
||||
|
||||
@@ -114,6 +114,18 @@ def _mcp_oauth_redirect_uri() -> str:
|
||||
return REDIRECT_URI
|
||||
|
||||
|
||||
def _is_legacy_google_flow(identifier: str) -> bool:
|
||||
"""Determine whether an identifier is the server_id of a configured Google OAuth MCP server."""
|
||||
if not identifier:
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == identifier).first()
|
||||
return bool(srv and srv.oauth_config)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"""Setup MCP routes with the provided manager."""
|
||||
|
||||
@@ -487,10 +499,14 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
db.close()
|
||||
|
||||
@router.get("/oauth/callback")
|
||||
async def oauth_callback(code: str, state: str, request: Request):
|
||||
async def oauth_callback(code: str = "", state: str = "", request: Request = None):
|
||||
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
||||
pending-state registry; Google flows fall through to the legacy path."""
|
||||
require_admin(request)
|
||||
if not state:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400)
|
||||
if not code:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
|
||||
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
@@ -498,8 +514,16 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
# Legacy Google path: state is the server_id
|
||||
return await _exchange_and_connect(state, code, request)
|
||||
|
||||
# Check if this callback belongs to a legacy Google flow vs expired generic flow
|
||||
if _is_legacy_google_flow(state):
|
||||
require_admin(request)
|
||||
return await _exchange_and_connect(state, code, request)
|
||||
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Expired",
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again.",
|
||||
), status_code=400)
|
||||
|
||||
@router.post("/oauth/exchange/{server_id}")
|
||||
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
||||
@@ -514,18 +538,28 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
except Exception:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
||||
|
||||
state = params.get("state", [None])[0]
|
||||
if not state:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400)
|
||||
|
||||
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
||||
# resolve it directly (the background connect finishes the handshake).
|
||||
state = params.get("state", [None])[0]
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if state and resolve_pending(state, code):
|
||||
if resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
# Check if this exchange belongs to a legacy Google flow vs expired generic flow
|
||||
if _is_legacy_google_flow(server_id):
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Expired",
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again.",
|
||||
), status_code=400)
|
||||
|
||||
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
||||
"""Exchange auth code for tokens and connect the MCP server."""
|
||||
|
||||
@@ -261,6 +261,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||
)
|
||||
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
try:
|
||||
suggestion_text = await llm_call_async(
|
||||
t_url,
|
||||
@@ -269,6 +271,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
temperature=0.2,
|
||||
max_tokens=500,
|
||||
headers=t_headers,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, _owner(request), sess),
|
||||
)
|
||||
try:
|
||||
suggestions = json.loads(suggestion_text)
|
||||
@@ -349,6 +352,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
endpoint_url = None
|
||||
model = None
|
||||
headers = {}
|
||||
sess = None
|
||||
|
||||
user = _owner(request)
|
||||
|
||||
@@ -453,6 +457,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
"Return ONLY valid JSON, no markdown fences."
|
||||
)
|
||||
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
try:
|
||||
raw = await llm_call_async(
|
||||
endpoint_url,
|
||||
@@ -464,6 +470,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
temperature=0.2,
|
||||
max_tokens=2000,
|
||||
headers=headers,
|
||||
reasoning_effort=effort_for_call(endpoint_url, model, user, sess),
|
||||
)
|
||||
|
||||
# Parse JSON
|
||||
|
||||
+56
-7
@@ -47,6 +47,7 @@ from src.endpoint_resolver import (
|
||||
build_headers,
|
||||
)
|
||||
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -472,7 +473,7 @@ def _truthy(value: str | None) -> bool:
|
||||
|
||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||
_MODEL_TOOL_MODES = {"none", "compact", "full"}
|
||||
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
|
||||
_MODEL_TOOL_MODE_ALIASES = {
|
||||
"regular": "full",
|
||||
"odysseus_compact": "compact",
|
||||
@@ -552,10 +553,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
||||
val = 0
|
||||
if val > 0:
|
||||
return float(max(1, min(60, val)))
|
||||
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
||||
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
||||
# offline before /v1/models is ready.
|
||||
return 10.0 if category == "local" else 2.0
|
||||
# Keep the background refresh short so a single dead endpoint cannot stall
|
||||
# the model picker. A timed-out refresh never clears a non-empty model
|
||||
# cache, so a briefly-warming local server is simply retried next round.
|
||||
return 2.0
|
||||
|
||||
|
||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||
@@ -1510,6 +1511,9 @@ def setup_model_routes(model_discovery):
|
||||
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||
_REFRESH_FAILURE_BASE = 300.0
|
||||
_REFRESH_FAILURE_MAX = 3600.0
|
||||
# Even a user-triggered refresh must not re-probe an endpoint that just
|
||||
# failed/timed out on every click; keep a short cooldown.
|
||||
_REFRESH_FAILURE_MIN = 60.0
|
||||
|
||||
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
||||
# Session-backed endpoints carry no static key; include their auth id
|
||||
@@ -1557,14 +1561,20 @@ def setup_model_routes(model_discovery):
|
||||
if mode in ("manual", "disabled") and not force:
|
||||
return False, info
|
||||
fails = int(state.get("fail_count") or 0)
|
||||
if fails and not force:
|
||||
if fails:
|
||||
last_failure = float(state.get("last_failure") or 0.0)
|
||||
empty_local = (
|
||||
not cached
|
||||
and category == "local"
|
||||
and str(getattr(ep, "id", "") or "").startswith("local-")
|
||||
)
|
||||
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
|
||||
delay = _failure_delay(fails, empty_local=empty_local)
|
||||
if force:
|
||||
# A user-triggered refresh gets a short cooldown instead of the
|
||||
# long background backoff, but still skips a just-failed
|
||||
# endpoint so repeated clicks cannot stall the picker.
|
||||
delay = min(delay, _REFRESH_FAILURE_MIN)
|
||||
if now - last_failure < delay:
|
||||
return False, info
|
||||
if cached and not force:
|
||||
interval = _endpoint_refresh_interval(ep, category)
|
||||
@@ -2163,6 +2173,19 @@ def setup_model_routes(model_discovery):
|
||||
# keep those container-local when the frontend marks them as such.
|
||||
base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local))
|
||||
|
||||
# SSRF hardening: validate the user-supplied URL before any outbound
|
||||
# request. Local-first means loopback/LAN endpoints are allowed by
|
||||
# default; non-HTTP(S) schemes and the cloud metadata range are always
|
||||
# rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown.
|
||||
# Note: the outbound fetches rely on httpx's default
|
||||
# follow_redirects=False — a redirect target is never dereferenced.
|
||||
ok, reason = check_outbound_url(
|
||||
base_url,
|
||||
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
|
||||
is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless"
|
||||
# Auto-generate name from URL if not provided
|
||||
if not name.strip():
|
||||
@@ -2399,6 +2422,16 @@ def setup_model_routes(model_discovery):
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base_url = resolve_url(base_url)
|
||||
base_url = _rewrite_loopback_for_docker(base_url)
|
||||
# SSRF hardening: validate the user-supplied URL before any outbound
|
||||
# request. Local-first means loopback/LAN endpoints are allowed by
|
||||
# default; non-HTTP(S) schemes and the cloud metadata range are always
|
||||
# rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown.
|
||||
ok, reason = check_outbound_url(
|
||||
base_url,
|
||||
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless"
|
||||
if is_featherless and requested_kind == "auto":
|
||||
@@ -2889,6 +2922,16 @@ def setup_model_routes(model_discovery):
|
||||
_new_base = _new_base[: -len(_suffix)].rstrip("/")
|
||||
_new_base = _normalize_base(_new_base)
|
||||
if _new_base:
|
||||
# SSRF hardening: PATCH rewrites the stored base_url, so
|
||||
# it needs the same guard as POST create/test — no
|
||||
# bypass through the update path. Follow-up fetches
|
||||
# rely on httpx's default follow_redirects=False.
|
||||
ok, reason = check_outbound_url(
|
||||
_new_base,
|
||||
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
ep.base_url = _new_base
|
||||
else:
|
||||
ep.is_enabled = not ep.is_enabled
|
||||
@@ -3055,4 +3098,10 @@ def setup_model_routes(model_discovery):
|
||||
|
||||
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||
router._search_endpoint_catalog = search_endpoint_catalog
|
||||
# Exposed for tests: the in-memory backoff state, its key function, and the
|
||||
# background refresh driver (probes endpoints in parallel with the short
|
||||
# per-endpoint timeout).
|
||||
router._refresh_state = _refresh_state
|
||||
router._refresh_key = _refresh_key
|
||||
router._refresh_caches_bg = _refresh_caches_bg
|
||||
return router
|
||||
|
||||
@@ -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">
|
||||
|
||||
+77
-13
@@ -6,17 +6,24 @@ import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Query
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Depends, Query
|
||||
import logging
|
||||
|
||||
from core.session_manager import SessionManager
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import SessionResponse
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
_auth_disabled,
|
||||
owner_filter,
|
||||
is_delegated_credential,
|
||||
require_chat_api_token_scope,
|
||||
)
|
||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||
from src.session_actions import is_session_recently_active
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
||||
|
||||
|
||||
def _sanitize_export_filename(name: str) -> str:
|
||||
@@ -234,9 +241,15 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["sessions"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
|
||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||
if is_delegated_credential(request):
|
||||
return False
|
||||
if not user:
|
||||
return False
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
@@ -267,6 +280,22 @@ def _reject_raw_endpoint_url_for_non_admin(
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
|
||||
|
||||
def _reject_delegated_session_options(
|
||||
request: Request,
|
||||
*,
|
||||
skip_validation: bool = False,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""Keep bearer credentials from exercising interactive-admin options."""
|
||||
if is_delegated_credential(request) and (
|
||||
skip_validation or bool((api_key or "").strip())
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"API tokens cannot supply endpoint credentials or skip endpoint validation",
|
||||
)
|
||||
|
||||
|
||||
def _persist_session_headers(session_id: str, headers: dict | None) -> bool:
|
||||
"""Persist endpoint auth headers for DB-backed session metadata."""
|
||||
delays = (0.05, 0.15, 0.35)
|
||||
@@ -480,6 +509,7 @@ def setup_session_routes(
|
||||
"has_documents": s.id in doc_session_ids,
|
||||
"has_images": s.id in img_session_ids,
|
||||
"mode": s.mode,
|
||||
"thinking_mode": s.thinking_mode or "off",
|
||||
"message_count": s.message_count or 0,
|
||||
})
|
||||
finally:
|
||||
@@ -501,6 +531,11 @@ def setup_session_routes(
|
||||
):
|
||||
skip_val = str(skip_validation).lower() == "true"
|
||||
user = effective_user(request)
|
||||
_reject_delegated_session_options(
|
||||
request,
|
||||
skip_validation=skip_val,
|
||||
api_key=api_key,
|
||||
)
|
||||
endpoint_api_key = ""
|
||||
endpoint_base_url = ""
|
||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||
@@ -757,7 +792,11 @@ def setup_session_routes(
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||
for m in messages:
|
||||
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
||||
sess.add_message(ChatMessage(
|
||||
m["role"],
|
||||
m["content"],
|
||||
metadata=sanitize_client_message_metadata(m.get("metadata")),
|
||||
))
|
||||
session_manager.save_sessions()
|
||||
return {"ok": True, "count": len(messages)}
|
||||
|
||||
@@ -1004,6 +1043,7 @@ def setup_session_routes(
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"model": s.model,
|
||||
"thinking_mode": s.thinking_mode or "off",
|
||||
"message_count": s.message_count or 0,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
@@ -1118,6 +1158,8 @@ def setup_session_routes(
|
||||
model: str = Form("gpt-4o"),
|
||||
rag: str = Form(None)
|
||||
):
|
||||
if is_delegated_credential(request):
|
||||
raise HTTPException(403, "This session type requires an interactive session")
|
||||
if not OPENAI_API_KEY:
|
||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||
sid = str(uuid.uuid4())
|
||||
@@ -1198,9 +1240,16 @@ def setup_session_routes(
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
owner = getattr(session, "owner", None) or effective_user(request)
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
||||
url, model, headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=getattr(session, "endpoint_url", None),
|
||||
fallback_model=getattr(session, "model", None),
|
||||
fallback_headers=getattr(session, "headers", None),
|
||||
owner=owner,
|
||||
)
|
||||
if not url or not model:
|
||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||
if not url or not model:
|
||||
@@ -1228,6 +1277,7 @@ def setup_session_routes(
|
||||
max_tokens=1024,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
reasoning_effort=effort_for_call(url, model, owner, session),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Manual compaction failed: %s", e)
|
||||
@@ -1270,7 +1320,8 @@ def setup_session_routes(
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
"""
|
||||
from src.llm_core import llm_call
|
||||
from src.llm_core import llm_call, llm_call_with_fallback
|
||||
from src.utility_effort import effort_for_call
|
||||
user = effective_user(request)
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
@@ -1416,7 +1467,7 @@ def setup_session_routes(
|
||||
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
||||
|
||||
# Pick an endpoint — prefer admin-configured task endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
|
||||
url, model, headers = resolve_task_endpoint(owner=user)
|
||||
if not url:
|
||||
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
||||
@@ -1442,8 +1493,12 @@ def setup_session_routes(
|
||||
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
||||
raw = llm_call_with_fallback(
|
||||
resolve_task_candidates(fallback_url=url, fallback_model=model,
|
||||
fallback_headers=headers, owner=user),
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, timeout=120,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||
# Extract JSON from response — handle markdown fences, leading text,
|
||||
# reasoning-model <think> blocks, and trailing commas.
|
||||
@@ -1561,7 +1616,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
|
||||
@@ -1571,19 +1632,22 @@ def setup_session_routes(
|
||||
# Server-side tool policy: non-admin owners silently lose the computer
|
||||
# tools (src/tool_security); surface that so the TUI can show it.
|
||||
try:
|
||||
from src.tool_security import blocked_tools_for_owner
|
||||
from src.tool_security import blocked_tools_for_owner, delegated_credential_blocked_tools
|
||||
|
||||
_blocked = blocked_tools_for_owner(owner)
|
||||
if is_delegated_credential(request):
|
||||
_blocked.update(delegated_credential_blocked_tools())
|
||||
except Exception:
|
||||
_blocked = set()
|
||||
_computer = {"bash", "python", "read_file", "write_file", "host_shell"}
|
||||
_policy = {
|
||||
"computer_tools": "restricted" if _computer & _blocked else "full",
|
||||
"reason": "non-admin owner" if _blocked else "single-user or admin",
|
||||
"reason": "API-token caller" if is_delegated_credential(request) else
|
||||
"non-admin owner" if _blocked else "single-user or admin",
|
||||
}
|
||||
_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,
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ from src.host_docker_access import (
|
||||
)
|
||||
from src.optional_deps import prepare_optional_dependency_import
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src import shutdown as _shutdown
|
||||
from src import process_lifecycle
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
# on Windows, so importing them unconditionally crashed app startup there
|
||||
|
||||
+129
-1694
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -611,12 +618,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
async def create_notification_log(request: Request):
|
||||
"""Persist an in-app toast so Settings can show notification history."""
|
||||
user = _owner(request)
|
||||
# Toast logging is best-effort. With auth disabled (or no user) there is
|
||||
# nothing meaningful to persist, so return success rather than a 401 —
|
||||
# a 401 here would trip the global fetch redirect-to-/login handler and
|
||||
# yank users out of the editor mid-edit.
|
||||
if not user:
|
||||
return {"success": False}
|
||||
raise HTTPException(401, "Authentication required")
|
||||
body = await request.json()
|
||||
message = str(body.get("body") or "").strip()[:2000]
|
||||
if not message:
|
||||
@@ -759,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:
|
||||
@@ -1242,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
|
||||
|
||||
+76
-25
@@ -21,7 +21,8 @@ from core.database import (
|
||||
Note,
|
||||
Session as DbSession,
|
||||
)
|
||||
from src.auth_helpers import effective_user
|
||||
from src.auth_helpers import effective_user, is_delegated_credential
|
||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||
from src.attachment_refs import attachment_refs_from_metadata
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
from src.path_confinement import is_inside
|
||||
@@ -34,6 +35,8 @@ from src.upload_handler import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||
UPLOAD_READ_SCOPES = frozenset({"chat", "documents:read", "documents:write"})
|
||||
UPLOAD_WRITE_SCOPES = frozenset({"chat", "documents:write"})
|
||||
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
|
||||
|
||||
def _upload_ids_from_persisted_text(value: object) -> set[str]:
|
||||
@@ -178,6 +181,52 @@ def setup_upload_routes(upload_handler):
|
||||
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
def _verify_upload_access(
|
||||
request: Request,
|
||||
file_owner: str | None,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> None:
|
||||
if auth_disabled():
|
||||
return
|
||||
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is None:
|
||||
raise HTTPException(503, "Authentication service unavailable")
|
||||
|
||||
if not getattr(auth_mgr, "is_configured", False):
|
||||
raise HTTPException(401, "Setup required")
|
||||
|
||||
current_user = effective_user(request)
|
||||
if not current_user or is_request_sentinel_owner(current_user):
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
# Delegated credentials (bearer API tokens):
|
||||
# Establish token owner identity but NEVER inherit interactive administrator privileges.
|
||||
if is_delegated_credential(request):
|
||||
token_owner = getattr(request.state, "api_token_owner", None)
|
||||
if not token_owner or is_request_sentinel_owner(token_owner):
|
||||
raise HTTPException(403, "API token has no valid owner")
|
||||
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
required_scopes = UPLOAD_WRITE_SCOPES if write else UPLOAD_READ_SCOPES
|
||||
if not token_scopes.intersection(required_scopes):
|
||||
raise HTTPException(403, f"API token missing required scope: {' or '.join(sorted(required_scopes))}")
|
||||
|
||||
# Non-owners and legacy ownerless uploads are strictly forbidden for delegated tokens.
|
||||
if file_owner is None or current_user != file_owner:
|
||||
raise HTTPException(404, "File not found")
|
||||
return
|
||||
|
||||
# Interactive session (cookie-authenticated human):
|
||||
# Owner access
|
||||
if file_owner is not None and current_user == file_owner:
|
||||
return
|
||||
|
||||
# Non-owner or legacy ownerless upload: administrator only
|
||||
if not (hasattr(auth_mgr, "is_admin") and auth_mgr.is_admin(current_user)):
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
def _valid_session_id_for_owner(db, session_id: str | None, owner: str | None) -> str | None:
|
||||
if not session_id:
|
||||
return None
|
||||
@@ -280,6 +329,25 @@ def setup_upload_routes(upload_handler):
|
||||
session_id = None
|
||||
if not files:
|
||||
raise HTTPException(400, "No files uploaded")
|
||||
|
||||
if not auth_disabled():
|
||||
app = getattr(request, "app", None)
|
||||
if app is not None:
|
||||
auth_mgr = getattr(getattr(app, "state", None), "auth_manager", None)
|
||||
if auth_mgr is None:
|
||||
raise HTTPException(503, "Authentication service unavailable")
|
||||
if not getattr(auth_mgr, "is_configured", False):
|
||||
raise HTTPException(401, "Setup required")
|
||||
current_user = effective_user(request)
|
||||
if not current_user or is_request_sentinel_owner(current_user):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if is_delegated_credential(request):
|
||||
token_owner = getattr(request.state, "api_token_owner", None)
|
||||
if not token_owner or is_request_sentinel_owner(token_owner):
|
||||
raise HTTPException(403, "API token has no valid owner")
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if not token_scopes.intersection(UPLOAD_WRITE_SCOPES):
|
||||
raise HTTPException(403, f"API token missing required scope: {' or '.join(sorted(UPLOAD_WRITE_SCOPES))}")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
out = []
|
||||
@@ -303,6 +371,8 @@ def setup_upload_routes(upload_handler):
|
||||
for u in files:
|
||||
try:
|
||||
owner = effective_user(request)
|
||||
if is_request_sentinel_owner(owner):
|
||||
owner = None
|
||||
meta = upload_handler.save_upload(u, client_ip, owner=owner)
|
||||
promoted_gallery_id = _promote_chat_image_to_gallery(meta, owner, session_id, gallery_id)
|
||||
item = {
|
||||
@@ -382,15 +452,8 @@ def setup_upload_routes(upload_handler):
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
if info:
|
||||
original_name = info.get("name", file_id)
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||
current_user = effective_user(request)
|
||||
file_owner = info.get("owner") if info else None
|
||||
if auth_configured:
|
||||
if not current_user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
||||
raise HTTPException(404, "File not found")
|
||||
_verify_upload_access(request, file_owner)
|
||||
path = _resolve_upload_path(file_id)
|
||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -471,15 +534,9 @@ def setup_upload_routes(upload_handler):
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
info = _load_upload_info(file_id)
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||
current_user = effective_user(request)
|
||||
file_owner = info.get("owner") if info else None
|
||||
if auth_configured:
|
||||
if not current_user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
||||
raise HTTPException(404, "File not found")
|
||||
_verify_upload_access(request, file_owner)
|
||||
current_user = effective_user(request)
|
||||
path = _resolve_upload_path(file_id)
|
||||
import mimetypes as _mt
|
||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or ""
|
||||
@@ -517,15 +574,9 @@ def setup_upload_routes(upload_handler):
|
||||
info = _load_upload_info(file_id)
|
||||
if not info:
|
||||
raise HTTPException(404, "File not found")
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||
current_user = effective_user(request)
|
||||
file_owner = info.get("owner")
|
||||
if auth_configured:
|
||||
if not current_user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
||||
raise HTTPException(404, "File not found")
|
||||
_verify_upload_access(request, file_owner, write=True)
|
||||
current_user = effective_user(request)
|
||||
_resolve_upload_path(file_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
Reference in New Issue
Block a user