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:
Alexandre Teixeira
2026-10-28 19:30:08 +00:00
94 changed files with 12199 additions and 477 deletions
+125 -13
View File
@@ -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
View File
@@ -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:])
+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,
)
+224 -91
View File
@@ -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
View File
@@ -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}")
+9 -6
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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)
+10 -3
View File
@@ -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"
+8 -5
View File
@@ -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.
+4 -3
View File
@@ -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: