mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 01:32:25 +01:00
merge(dev): reconcile photo editor security and upstream changes
- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py - Enforce strict server-authorized endpoint resolution and reject client URL overrides - Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata - Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues - Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357 - Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression - Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
This commit is contained in:
+125
-13
@@ -569,8 +569,72 @@ async def auto_name_session(session_manager, sess):
|
||||
if not first_msg:
|
||||
return
|
||||
|
||||
from src.owner_identity import is_request_sentinel_owner
|
||||
owner = getattr(sess, "owner", None)
|
||||
invalid_owner = (
|
||||
not isinstance(owner, str) or not owner.strip()
|
||||
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
||||
)
|
||||
if invalid_owner:
|
||||
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
||||
return
|
||||
|
||||
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:
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (build_chat_url, build_headers,
|
||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
||||
from src.owner_identity import auth_disabled
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
if not owner and not auth_disabled():
|
||||
raise ValueError("Session has no owner")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
title_url = build_chat_url(base)
|
||||
title_headers = build_headers(key, base)
|
||||
finally:
|
||||
db.close()
|
||||
title = await llm_call_async(
|
||||
title_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=title_headers,
|
||||
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)
|
||||
@@ -581,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
||||
return
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
from src.owner_identity import auth_disabled
|
||||
if not owner and not auth_disabled():
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
||||
return
|
||||
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||
)
|
||||
@@ -588,24 +660,64 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.debug("[auto-name] No model provided, skipping")
|
||||
return
|
||||
|
||||
sess_url = getattr(sess, "endpoint_url", None)
|
||||
if t_url and sess_url and t_url == sess_url:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
t_url = build_chat_url(base)
|
||||
t_headers = build_headers(key, base)
|
||||
except Exception as e:
|
||||
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
||||
t_url, t_model, t_headers = None, None, None
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
u, m, h = resolve_endpoint(prefix, owner=owner)
|
||||
if u and m:
|
||||
t_url, t_model, t_headers = u, m, h
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not t_url or not t_model:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# max_tokens big enough that reasoning models (Minimax M2,
|
||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||
# plus the actual title — 200 used to clip them mid-reasoning
|
||||
# so strip_think left an empty string and no rename happened.
|
||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
t_model,
|
||||
[
|
||||
{"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 — just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
t_model,
|
||||
[
|
||||
{"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 — just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||
title = ""
|
||||
|
||||
title = title.strip().strip('"\'').strip()
|
||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||
|
||||
+102
-11
@@ -27,11 +27,13 @@ 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,
|
||||
)
|
||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||
from src import agent_runs
|
||||
from src import shutdown as _shutdown
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
@@ -329,6 +331,7 @@ 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)
|
||||
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
||||
|
||||
|
||||
def _mentions_pdf_url(value: str) -> bool:
|
||||
@@ -2688,6 +2691,23 @@ def setup_chat_routes(
|
||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||
# image-edit chat turn) may carry the open image-editor session id and a
|
||||
# canvas snapshot so the editor tool family can drive it in-process.
|
||||
# The id is quoted into a system message, so accept only an
|
||||
# identifier; any other value is treated as no open editor.
|
||||
editor_session = str(
|
||||
form_data.get("editor_session")
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
||||
editor_session = ""
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
or bool(editor_session)
|
||||
)
|
||||
logger.info(
|
||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||
chat_mode, active_doc_id, active_doc_state,
|
||||
@@ -3270,7 +3290,34 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc), workspace=bool(workspace),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
) if _use_turn_contract else frozenset()
|
||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
# Scoped photo_editor family: offered ONLY for an explicit editor
|
||||
# request (Quick Edit popup / editor session) or an image-edit chat
|
||||
# turn carrying an image. When active the turn offers just the editor
|
||||
# tools plus ask_user, never the general tool list.
|
||||
_photo_editor_active = bool(_use_turn_contract) and (
|
||||
photo_editor_requested
|
||||
or (
|
||||
"image_editing" in _turn_capabilities
|
||||
and bool(ctx.preprocessed.attachment_meta)
|
||||
)
|
||||
)
|
||||
if _photo_editor_active:
|
||||
if editor_session:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
|
||||
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
|
||||
'editor and confirm the result with a screenshot.'}
|
||||
else:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The user attached an image but no image editor is open, so the editor tools cannot '
|
||||
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
|
||||
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
|
||||
'tools will then apply it.'}
|
||||
ctx.messages.insert(0, editor_context)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
|
||||
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||
if image_refs:
|
||||
@@ -3307,6 +3354,11 @@ def setup_chat_routes(
|
||||
# pasted revision request. This only offers permitted schemas;
|
||||
# it never requires or performs a document mutation.
|
||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||
# Scoped photo_editor family (see above): when active the turn offers
|
||||
# just the editor tools plus ask_user, never the general tool list.
|
||||
if _photo_editor_active:
|
||||
_turn_capabilities = frozenset({"photo_editor"})
|
||||
_active_turn_capabilities = _turn_capabilities
|
||||
# Same decision that prepared the turn; it only stamps the contract
|
||||
# inside the agent-contract branch below.
|
||||
_clean_v3_preview = _compact_preview_turn
|
||||
@@ -3474,7 +3526,7 @@ def setup_chat_routes(
|
||||
if not _privs.get("can_use_documents", True):
|
||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||
if not _privs.get("can_generate_images", True):
|
||||
disabled_tools.update({"generate_image", "edit_image"})
|
||||
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||
if not _privs.get("can_manage_memory", True):
|
||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||
if not _privs.get("can_use_research", True):
|
||||
@@ -3556,9 +3608,22 @@ def setup_chat_routes(
|
||||
"document_editor": not {
|
||||
"manage_documents", "create_document", "edit_document", "update_document",
|
||||
}.issubset(disabled_tools),
|
||||
# A photo-editing chat turn can only reach a canvas when the image
|
||||
# editor is actually open. The compact runtime uses this to reply
|
||||
# (open the image in the editor) instead of looping on editor tools
|
||||
# that have no canvas to drive.
|
||||
"photo_editor_open": bool(editor_session),
|
||||
}
|
||||
# Capture permission state before schema selection/reconciliation.
|
||||
# Only deterministic request intent supplies grants, never inventory.
|
||||
# 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_raw(
|
||||
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||
) == "spec"
|
||||
except Exception:
|
||||
_spec_surface = False
|
||||
_request_authority = request_authority_for_http(
|
||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||
history=_turn_history, policy=tool_policy,
|
||||
@@ -3566,9 +3631,9 @@ 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 [])),
|
||||
capabilities=({'search_browser'} if (
|
||||
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||
_explicit_browser_intent or _external_discovery_intent
|
||||
) else ()),
|
||||
) else set())),
|
||||
)
|
||||
if exact_tool_approval is not None:
|
||||
from src.agent_runtime.authority import RequestAuthority
|
||||
@@ -3758,7 +3823,9 @@ def setup_chat_routes(
|
||||
# personal manager; core shell/Web tools are not task
|
||||
# fallbacks. Other turns retain warm and workspace tools.
|
||||
extra_tools=(
|
||||
frozenset()
|
||||
frozenset({"ask_user"})
|
||||
if _active_turn_capabilities == frozenset({"photo_editor"})
|
||||
else frozenset()
|
||||
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||
_active_turn_capabilities in (
|
||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||
@@ -3960,7 +4027,7 @@ def setup_chat_routes(
|
||||
_heartbeat_counter = 0
|
||||
_last_progress = {}
|
||||
_sent_avg = False
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session)
|
||||
if not status or status["status"] != "running":
|
||||
break
|
||||
@@ -4222,8 +4289,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"]
|
||||
@@ -4667,8 +4732,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
|
||||
@@ -4852,6 +4915,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
|
||||
@@ -4962,6 +5044,8 @@ def setup_chat_routes(
|
||||
raises before reaching a mode-specific finally block."""
|
||||
try:
|
||||
async for chunk in stream_with_save():
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
_active_streams.pop(session, None)
|
||||
@@ -5056,7 +5140,12 @@ def setup_chat_routes(
|
||||
if rec is None:
|
||||
if agent_runs.is_active(session_id):
|
||||
return {"status": "streaming", "detached": True}
|
||||
raise HTTPException(404, "No active stream for this session")
|
||||
# No active stream: report an explicit idle state with HTTP 200.
|
||||
# A 404 here made every session load look like a failed request in
|
||||
# the browser console. The client distinguishes "no stream" by the
|
||||
# body (status != "streaming"), and still tolerates a 404 from an
|
||||
# older server, so this stays backward compatible.
|
||||
return {"status": "idle"}
|
||||
return rec
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -5155,6 +5244,8 @@ def setup_chat_routes(
|
||||
max_tokens=0,
|
||||
tools=None,
|
||||
):
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+224
-91
@@ -21,11 +21,12 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from src import editor_style_guide as style_guide
|
||||
@@ -1169,17 +1170,90 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
op = str(step.get("op") or "")
|
||||
if op not in _OP_SET:
|
||||
continue
|
||||
out.append({"op": op, "params": step.get("params") or {}})
|
||||
params = step.get("params")
|
||||
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||
return out
|
||||
|
||||
|
||||
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _prompt(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > 4096:
|
||||
raise HTTPException(413, "Editor prompt is too long")
|
||||
return text
|
||||
|
||||
|
||||
def _bridge_timeout(value: Any) -> float:
|
||||
try:
|
||||
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
return timeout
|
||||
|
||||
|
||||
def _is_admin(request: Request) -> bool:
|
||||
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
|
||||
|
||||
Delegated credentials (bearer API tokens) are explicitly rejected:
|
||||
per src/tool_security.py, bearer tokens are long-lived credentials handed to
|
||||
third parties/scripts and must remain capped at non-admin policies even
|
||||
when minted by an administrator.
|
||||
"""
|
||||
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
|
||||
if getattr(request.state, "api_token", False):
|
||||
return False
|
||||
|
||||
# 2. In-process internal tool bypass (loopback agent tools)
|
||||
try:
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||
import secrets
|
||||
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||
return True
|
||||
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
|
||||
try:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if _auth_disabled():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Middleware require_admin
|
||||
try:
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
pass
|
||||
|
||||
# 5. Direct session/user check when request.state.current_user is set
|
||||
try:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr and getattr(auth_mgr, "is_configured", False):
|
||||
from src.auth_helpers import get_current_user
|
||||
user = get_current_user(request) or getattr(request.state, "current_user", None)
|
||||
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
@@ -1187,7 +1261,6 @@ def _user(request: Request) -> Optional[str]:
|
||||
if getattr(request.state, "api_token", False):
|
||||
from src.auth_helpers import require_api_token_scope
|
||||
return require_api_token_scope(request, "chat")
|
||||
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
@@ -1229,17 +1302,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if _EDITOR_PLANNER is None:
|
||||
set_editor_planner(_default_editor_planner)
|
||||
|
||||
# Probe the local editor-agent model server (non-blocking) so the planner
|
||||
# can use it for vision planning when reachable.
|
||||
editor_agent_model.startup_probe()
|
||||
|
||||
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
||||
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
||||
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
||||
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[str, List[dict]] = {}
|
||||
pending: Dict[str, asyncio.Future] = {}
|
||||
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||
max_pending = 1024
|
||||
|
||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||
@@ -1276,17 +1346,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
del bl[0: len(bl) - _QUEUE_MAX]
|
||||
return 0
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
||||
return f"{owner or 'anon'}:{session or 'default'}"
|
||||
def _expire_command(session, cid, fut):
|
||||
entry = pending.get(cid)
|
||||
if entry is not None and entry[1] is fut:
|
||||
pending.pop(cid, None)
|
||||
if not fut.done():
|
||||
fut.cancel()
|
||||
queued = backlog.get(session)
|
||||
if queued:
|
||||
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
|
||||
if not backlog[session]:
|
||||
backlog.pop(session, None)
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
|
||||
# Usernames and session ids may both contain colons.
|
||||
return (owner, str(session or 'default'))
|
||||
|
||||
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
|
||||
return f"{scope[0] or 'anon'}:{scope[1]}"
|
||||
|
||||
@router.post("/api/editor/plan")
|
||||
async def plan(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
body = await _json_object(request)
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
state = body.get("state") or {}
|
||||
mode = str(body.get("mode") or "plan").strip().lower()
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1390,31 +1473,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
@router.post("/api/editor/bridge/command")
|
||||
async def bridge_command(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
session = _scoped(body.get("session"), owner)
|
||||
steps = _normalize_steps(body)
|
||||
wait = bool(body.get("wait"))
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
ids: List[str] = []
|
||||
wait_ids: List[str] = []
|
||||
if len(pending) + len(steps) > max_pending:
|
||||
raise HTTPException(429, "Too many pending editor commands")
|
||||
for step in steps:
|
||||
cid = uuid.uuid4().hex
|
||||
ids.append(cid)
|
||||
if wait:
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
wait_ids.append(cid)
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||
if wait and len(wait_ids) == 1:
|
||||
|
||||
if wait and len(steps) == 1:
|
||||
cid = ids[0]
|
||||
entry = pending.get(cid)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||
if isinstance(res, dict):
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res.setdefault("completed", bool(res.get("ok")))
|
||||
return JSONResponse(res)
|
||||
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(wait_ids[0], None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
||||
return JSONResponse({"queued": ids, "session": session})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||
finally:
|
||||
_expire_command(session, cid, fut)
|
||||
else:
|
||||
for cid in ids:
|
||||
entry = pending.get(cid)
|
||||
if entry is not None:
|
||||
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
|
||||
|
||||
return JSONResponse({
|
||||
"queued": ids,
|
||||
"session": _scope_label(session),
|
||||
"status": "queued",
|
||||
"completed": False,
|
||||
"ok": False,
|
||||
})
|
||||
|
||||
@router.get("/api/editor/bridge/events")
|
||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||
@@ -1439,38 +1540,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/editor/bridge/result")
|
||||
async def bridge_result(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
fut = pending.pop(cid, None)
|
||||
entry = pending.get(cid)
|
||||
if entry is None:
|
||||
return JSONResponse({"ok": True})
|
||||
if entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
|
||||
_, fut = entry
|
||||
if fut is not None and not fut.done():
|
||||
is_ok = bool(body.get("ok"))
|
||||
fut.set_result({
|
||||
"id": cid, "ok": bool(body.get("ok")),
|
||||
"result": body.get("result"), "error": body.get("error"),
|
||||
"id": cid,
|
||||
"ok": is_ok,
|
||||
"status": "completed" if is_ok else "failed",
|
||||
"completed": is_ok,
|
||||
"result": body.get("result"),
|
||||
"error": body.get("error"),
|
||||
"screenshot": body.get("screenshot"),
|
||||
})
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@router.post("/api/editor/bridge/await")
|
||||
async def bridge_await(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
fut = pending.get(cid)
|
||||
if fut is None:
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = fut
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
entry = pending.get(cid)
|
||||
if entry is None or entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||
if pending.get(cid) == (owner, fut):
|
||||
pending.pop(cid, None)
|
||||
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res["completed"] = bool(res.get("ok"))
|
||||
return JSONResponse(res)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||
|
||||
_AGENT_SYSTEM_PROMPT = (
|
||||
"You are the photo editor agent. You drive the open image editor and "
|
||||
@@ -1521,17 +1634,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
overwritten.
|
||||
"""
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
from src.settings import get_setting
|
||||
disabled = get_setting("disabled_tools", []) or []
|
||||
if not isinstance(disabled, (list, tuple, set)):
|
||||
disabled = []
|
||||
if "plan_edit" in disabled:
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1542,7 +1652,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
scoped = _scoped(session, owner)
|
||||
trace: List[Dict[str, Any]] = [
|
||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
||||
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||
]
|
||||
if not prompt:
|
||||
return JSONResponse({
|
||||
@@ -1639,8 +1749,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
"results": [],
|
||||
"agent_model": model_used,
|
||||
})
|
||||
# This route dispatches tools directly, outside execute_tool_block.
|
||||
# Apply the same administrator tool switches before any step runs.
|
||||
needed = {"plan_edit"}
|
||||
for step in steps:
|
||||
op = step.get("op")
|
||||
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||
if needed.intersection(disabled):
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
# No image provider: stop before any edit runs and tell the user plainly
|
||||
# (zero canvas changes; nothing to roll back).
|
||||
if _plan_uses_ai_content(steps):
|
||||
from src.auth_helpers import require_privilege
|
||||
require_privilege(request, "can_generate_images")
|
||||
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||
return JSONResponse({
|
||||
@@ -1673,24 +1794,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||
break
|
||||
tool_payload = dict(params)
|
||||
tool_payload.setdefault("session", session)
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload.setdefault("model", model_choice)
|
||||
tool_payload["model"] = model_choice
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
# selection step (the masked path was losing the canvas image).
|
||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||
tool_payload.setdefault("image", screenshot)
|
||||
tool_payload["image"] = screenshot
|
||||
else:
|
||||
tool_payload.pop("image", None)
|
||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||
try:
|
||||
_desc, res = await tool_cls().execute(
|
||||
json.dumps(tool_payload),
|
||||
{"owner": owner, "session_id": scoped},
|
||||
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||
"incognito": body.get("incognito") is True},
|
||||
)
|
||||
except Exception as exc:
|
||||
res = {"error": str(exc), "exit_code": 1}
|
||||
except Exception:
|
||||
logger.exception("Editor image operation failed")
|
||||
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||
res = res if isinstance(res, dict) else {}
|
||||
ok = res.get("exit_code", 0) == 0
|
||||
step_result = {
|
||||
@@ -1708,15 +1835,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
break
|
||||
continue
|
||||
cid = uuid.uuid4().hex
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||
try:
|
||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||
break
|
||||
finally:
|
||||
_expire_command(scoped, cid, fut)
|
||||
results.append(res)
|
||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||
if res.get("ok") is False:
|
||||
@@ -1727,13 +1856,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if steps and not any(r.get("ok") is False for r in results):
|
||||
try:
|
||||
_info_id = uuid.uuid4().hex
|
||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
||||
_info_fut = asyncio.get_event_loop().create_future()
|
||||
pending[_info_id] = (owner, _info_fut)
|
||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
||||
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||
final_state = _info["result"]
|
||||
except Exception:
|
||||
final_state = {}
|
||||
finally:
|
||||
_expire_command(scoped, _info_id, _info_fut)
|
||||
planned_reply = reply
|
||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||
if special_note and special_note.strip() not in reply:
|
||||
@@ -1770,8 +1902,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
is_admin = _is_admin(request)
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
try:
|
||||
@@ -1795,11 +1926,18 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# router uses.
|
||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||
endpoint_id = getattr(ep, "id", None)
|
||||
provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||
provider = getattr(ep, "name", None)
|
||||
if not provider:
|
||||
provider = (
|
||||
getattr(ep, "base_url", "")
|
||||
if is_admin
|
||||
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||
)
|
||||
provider = provider or "image"
|
||||
for name in _endpoint_enabled_models(ep):
|
||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||
continue
|
||||
key = (str(endpoint_id), name)
|
||||
key = (str(endpoint_id or provider), name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
@@ -1831,21 +1969,20 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
For non-admin users, private URLs and internal probe status are omitted.
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
is_admin = _is_admin(request)
|
||||
try:
|
||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||
if not is_admin:
|
||||
sanitized = []
|
||||
for m in models:
|
||||
m_copy = dict(m)
|
||||
m_copy.pop("base_url", None)
|
||||
sanitized.append(m_copy)
|
||||
models = sanitized
|
||||
except Exception:
|
||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||
models = []
|
||||
|
||||
safe_models = []
|
||||
for m in models:
|
||||
entry = dict(m)
|
||||
if not is_admin:
|
||||
entry.pop("base_url", None)
|
||||
safe_models.append(entry)
|
||||
|
||||
probe_info = {}
|
||||
if is_admin:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
@@ -1854,7 +1991,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
|
||||
return JSONResponse({
|
||||
"models": safe_models,
|
||||
"models": models,
|
||||
"probe": probe_info,
|
||||
})
|
||||
|
||||
@@ -1862,21 +1999,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
async def editor_self_check(request: Request) -> JSONResponse:
|
||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||
|
||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
||||
cannot call it directly and the self-check silently "passed". This route
|
||||
A configured vision server may not provide browser CORS headers. This route
|
||||
proxies the two images + intent to the agent VLM and returns
|
||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||
``reason="unavailable"`` on any failure so the client never loops).
|
||||
"""
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
result = await editor_agent_model.self_check(
|
||||
body.get("before"),
|
||||
body.get("after"),
|
||||
str(body.get("prompt") or body.get("intent") or ""),
|
||||
_prompt(body.get("prompt") or body.get("intent")),
|
||||
model=str(body.get("model") or "").strip() or None,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
+133
-73
@@ -1827,18 +1827,25 @@ def setup_email_routes():
|
||||
# a cached message body.
|
||||
return (account_id or "", folder, str(uid), owner)
|
||||
|
||||
def _list_cache_get(key):
|
||||
v = _LIST_CACHE.get(key)
|
||||
if not v: return None
|
||||
if v[0] < _time.monotonic():
|
||||
_LIST_CACHE.pop(key, None)
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
_LIST_STALE = {}
|
||||
_LIST_LOCK = _threading.RLock()
|
||||
_LIST_CACHE_LOCK = _LIST_LOCK
|
||||
# Invalidation fences reads already in flight, including background refresh.
|
||||
_LIST_EPOCH = [0]
|
||||
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
||||
|
||||
def _list_cache_get(key):
|
||||
with _LIST_LOCK:
|
||||
v = _LIST_CACHE.get(key)
|
||||
if not v: return None
|
||||
if v[0] < _time.monotonic():
|
||||
_LIST_CACHE.pop(key, None)
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
def _list_stale_get(key):
|
||||
return _LIST_STALE.get(key)
|
||||
with _LIST_LOCK:
|
||||
return _LIST_STALE.get(key)
|
||||
|
||||
def _list_stale_put(key, value):
|
||||
_LIST_STALE[key] = value
|
||||
@@ -1849,13 +1856,13 @@ def setup_email_routes():
|
||||
_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)
|
||||
with _LIST_LOCK:
|
||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||
# Also snapshot the last-good response for stale-while-revalidate.
|
||||
_list_stale_put(key, value)
|
||||
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.
|
||||
@@ -1863,15 +1870,20 @@ def setup_email_routes():
|
||||
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)
|
||||
with _LIST_LOCK:
|
||||
if cache_key in _LIST_REFRESHING:
|
||||
return
|
||||
_LIST_REFRESHING.add(cache_key)
|
||||
epoch = _LIST_EPOCH[0]
|
||||
|
||||
def _run():
|
||||
try:
|
||||
refreshed = _list_emails_sync(*sync_args)
|
||||
if refreshed and not refreshed.get("error"):
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
with _LIST_LOCK:
|
||||
if epoch != _LIST_EPOCH[0]:
|
||||
return
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
try:
|
||||
_schedule_recent_email_warm(
|
||||
refreshed.get("emails") or [], folder, account_id, owner)
|
||||
@@ -1880,7 +1892,8 @@ def setup_email_routes():
|
||||
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)
|
||||
with _LIST_LOCK:
|
||||
_LIST_REFRESHING.discard(cache_key)
|
||||
|
||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||
|
||||
@@ -1917,37 +1930,74 @@ def setup_email_routes():
|
||||
hook(account_id, folder)
|
||||
except Exception:
|
||||
pass
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
return
|
||||
for k in list(_LIST_CACHE.keys()):
|
||||
k_acct = k[0] if len(k) > 0 else ""
|
||||
k_folder = k[1] if len(k) > 1 else ""
|
||||
if (account_id is None or k_acct == (account_id or "")) and \
|
||||
(folder is None or k_folder == folder):
|
||||
_LIST_CACHE.pop(k, None)
|
||||
with _LIST_LOCK:
|
||||
_LIST_EPOCH[0] += 1
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
_LIST_STALE.clear()
|
||||
return
|
||||
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||
for k in list(cache.keys()):
|
||||
k_acct = k[0] if len(k) > 0 else ""
|
||||
k_folder = k[1] if len(k) > 1 else ""
|
||||
if (account_id is None or k_acct == (account_id or "")) and \
|
||||
(folder is None or k_folder == folder):
|
||||
cache.pop(k, None)
|
||||
|
||||
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
||||
with _LIST_LOCK:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _index_delete(owner, account_id, folder, uid):
|
||||
with _LIST_LOCK:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||
uid_s = str(uid)
|
||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
with _LIST_LOCK:
|
||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_CACHE[key] = (expires_at, value)
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_CACHE[key] = (expires_at, value)
|
||||
if key in _LIST_STALE:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
for key, value in list(_LIST_STALE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
def _read_cache_get(key):
|
||||
v = _READ_CACHE.get(key)
|
||||
@@ -2327,6 +2377,8 @@ def setup_email_routes():
|
||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||
the fallback config lookup is scoped to this user's accounts only.
|
||||
"""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
conn = None
|
||||
conn_ok = False
|
||||
try:
|
||||
@@ -2621,7 +2673,9 @@ def setup_email_routes():
|
||||
if parsed:
|
||||
fetched_emails.append(parsed)
|
||||
cached_rows[parsed["uid"]] = parsed
|
||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||
for uid_num in uid_order:
|
||||
row = cached_rows.get(uid_num)
|
||||
if not row:
|
||||
@@ -2915,7 +2969,7 @@ def setup_email_routes():
|
||||
# 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):
|
||||
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||
snapshot = _list_stale_get(ck)
|
||||
if snapshot is None:
|
||||
try:
|
||||
@@ -2950,6 +3004,8 @@ def setup_email_routes():
|
||||
ck, folder, account_id, owner,
|
||||
)
|
||||
return payload
|
||||
with _LIST_CACHE_LOCK:
|
||||
cache_generation = _LIST_CACHE_GENERATION[0]
|
||||
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 "",
|
||||
@@ -2958,7 +3014,9 @@ def setup_email_routes():
|
||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||
_list_cache_put(ck, result)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_list_cache_put(ck, result)
|
||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||
if elapsed_ms > 1500:
|
||||
logger.warning(
|
||||
@@ -3306,7 +3364,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||
if moved:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception:
|
||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||
@@ -3323,7 +3381,7 @@ def setup_email_routes():
|
||||
if deleted:
|
||||
conn.expunge()
|
||||
if deleted:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception as exc:
|
||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||
@@ -3405,7 +3463,7 @@ def setup_email_routes():
|
||||
if moved:
|
||||
changed += 1
|
||||
cleaned_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
@@ -3494,6 +3552,8 @@ def setup_email_routes():
|
||||
folder (Gmail does), we transparently swap to All Mail so the
|
||||
search surfaces archived / labelled emails too. Plain IMAP
|
||||
accounts fall back to whatever folder the caller specified."""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
if not q or len(q) < 2:
|
||||
return {"emails": [], "total": 0, "query": q}
|
||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||
@@ -3637,7 +3697,9 @@ def setup_email_routes():
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||
continue
|
||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||
|
||||
return {
|
||||
"emails": emails,
|
||||
@@ -3774,8 +3836,7 @@ def setup_email_routes():
|
||||
# accepted it, so the index and list cache cannot drift ahead of
|
||||
# the mailbox.
|
||||
if mark_seen and not mark_seen_failed:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
|
||||
related_attachments = []
|
||||
if full and not _has_visible_attachments(msg):
|
||||
@@ -3914,8 +3975,7 @@ def setup_email_routes():
|
||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||
if status != "OK":
|
||||
return False
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||
@@ -5147,7 +5207,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5167,7 +5227,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "flagged": bool(on)}
|
||||
except Exception as e:
|
||||
@@ -5185,7 +5245,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5205,7 +5265,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5236,7 +5296,7 @@ def setup_email_routes():
|
||||
# click or by another mailbox client; it is already gone
|
||||
# from the requested folder, so do not trap the UI on a
|
||||
# permanent “Email not found” error.
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "already_deleted": True}
|
||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||
@@ -5254,9 +5314,9 @@ def setup_email_routes():
|
||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||
return {"success": False, "error": "Could not move email to Trash"}
|
||||
_email_index_delete(owner, account_id, folder, resolved_uid)
|
||||
_index_delete(owner, account_id, folder, resolved_uid)
|
||||
if resolved_uid != str(uid):
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5272,7 +5332,7 @@ def setup_email_routes():
|
||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
conn.expunge()
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5340,7 +5400,7 @@ def setup_email_routes():
|
||||
pending_expunge.append(uid)
|
||||
continue
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_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)
|
||||
@@ -5362,7 +5422,7 @@ def setup_email_routes():
|
||||
if expunge_ok:
|
||||
for uid in pending_expunge:
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_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.
|
||||
@@ -5481,7 +5541,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, dest):
|
||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5567,7 +5627,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
@@ -5583,7 +5643,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -6250,7 +6310,7 @@ def setup_email_routes():
|
||||
try:
|
||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||
_invalidate_list_cache(_account_id, _source_folder)
|
||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||
|
||||
@@ -785,14 +785,17 @@ async def _forward_seneca_image_op(
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
||||
result = r.json()
|
||||
try:
|
||||
result = r.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
|
||||
raw_b64: Optional[str] = None
|
||||
used_model = ""
|
||||
if isinstance(result, dict):
|
||||
used_model = str(result.get("model") or "")
|
||||
data = result.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
item = data[0] or {}
|
||||
item = data[0] if isinstance(data[0], dict) else {}
|
||||
raw_b64 = item.get("b64_json") or None
|
||||
if not raw_b64 and item.get("url"):
|
||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||
@@ -1848,8 +1851,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
logger.debug(
|
||||
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
||||
body.get("prompt", ""), oa_model, size, base,
|
||||
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
|
||||
oa_model, size, base,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
if is_openrouter:
|
||||
@@ -1984,9 +1987,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
||||
logger.debug(
|
||||
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
||||
"inpaint_proxy self-hosted /images/edits -> model=%r "
|
||||
"size=%r steps=%r endpoint=%s",
|
||||
data.get("prompt"), data.get("model"), data.get("size"),
|
||||
data.get("model"), data.get("size"),
|
||||
data.get("steps"), base,
|
||||
)
|
||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
import html
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
@@ -12,7 +13,7 @@ import logging
|
||||
import httpx
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
from core.middleware import require_admin
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
|
||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||
"""
|
||||
require_admin(request)
|
||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||
# Same boundary as POST /api/tools: an agent turn must not be able
|
||||
# to re-enable MCP tools an admin switched off.
|
||||
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import socket
|
||||
import secrets
|
||||
import time as _time
|
||||
import logging
|
||||
import threading
|
||||
@@ -3091,6 +3092,12 @@ def setup_model_routes(model_discovery):
|
||||
def update_tools(body: ToolsUpdate, request: Request):
|
||||
"""Update which tools are disabled."""
|
||||
require_admin(request)
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||
# Owner impersonation can carry an admin username, so check the
|
||||
# authenticated transport rather than request.state.current_user.
|
||||
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
|
||||
settings = _load_settings()
|
||||
settings["disabled_tools"] = body.disabled
|
||||
_save_settings(settings)
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
@@ -285,11 +286,17 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# No in-flight or persisted research for this session is a normal state
|
||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||
# explicit empty status so loading a session does not log a spurious
|
||||
# 404; every client treats a non-"running" status as idle anyway.
|
||||
# Research owned by ANOTHER user answers identically, so the response
|
||||
# does not reveal whether that research exists.
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
try:
|
||||
source_state = research_handler.get_source_state(session_id)
|
||||
if isinstance(source_state, str) and source_state:
|
||||
@@ -657,7 +664,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_payload = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
|
||||
@@ -1320,7 +1320,7 @@ 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()
|
||||
@@ -1467,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)
|
||||
@@ -1493,9 +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,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
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.
|
||||
|
||||
@@ -27,6 +27,7 @@ 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 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
|
||||
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
# Check client disconnect
|
||||
if await request.is_disconnected():
|
||||
# tmux keeps running — that's the whole point
|
||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
if await request.is_disconnected():
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||
return
|
||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||
try:
|
||||
buf = b""
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
if buf:
|
||||
|
||||
Reference in New Issue
Block a user