Merge current dev and refresh PR #6738 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:22 +00:00
450 changed files with 60479 additions and 11016 deletions
+8 -1
View File
@@ -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
View File
@@ -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")
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+11 -7
View File
@@ -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):
+4 -4
View File
@@ -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
+3
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
+77 -41
View File
@@ -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)
+41 -7
View File
@@ -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."""
+7
View File
@@ -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
View File
@@ -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
+17 -4
View File
@@ -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
View File
@@ -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,
}
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -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
View File
@@ -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()