From d105fbde95b065005334893ded8c6e5e33927dfe Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:36:17 +0000 Subject: [PATCH] fix(editor): harden bridge isolation and completion semantics --- docs/EDITOR_AGENT_API.md | 15 +- routes/chat_routes.py | 2 +- routes/editor_agent_routes.py | 300 ++++++++--- routes/email/email_routes.py | 135 +++-- routes/gallery/gallery_routes.py | 15 +- src/agent_tools/editor_tools.py | 85 ++- src/editor_agent_model.py | 17 +- src/editor_style_guide.py | 14 +- src/fixture_email.py | 6 - src/settings.py | 5 +- src/tool_capabilities.py | 10 +- src/tool_schemas.py | 8 +- src/turn_contract.py | 12 - tests/test_agent_editor_routes.py | 499 +++++++++++++++++- tests/test_editor_agent_model_resolution.py | 8 + .../test_email_list_stale_while_revalidate.py | 60 +++ tests/test_gallery_cloud_inpaint.py | 20 +- tests/test_gallery_seneca_image_ops.py | 23 + tests/test_photo_editor_style_guide.py | 8 + 19 files changed, 1037 insertions(+), 205 deletions(-) diff --git a/docs/EDITOR_AGENT_API.md b/docs/EDITOR_AGENT_API.md index c873d28d9..b53873785 100644 --- a/docs/EDITOR_AGENT_API.md +++ b/docs/EDITOR_AGENT_API.md @@ -119,19 +119,26 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`). - The page runs the op via the control API and replies: `POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`. - Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`. - With `wait:true` the call blocks and returns the editor's result. + With `wait:true` and one step the call blocks and returns the editor's result. + Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id. + Timeouts are seconds, greater than zero and at most 900. Pending results expire + after the command timeout; an unknown or another owner's id returns 404. Round-trip example (authenticated; `` = `window.__geEditorBridge.session`): ```bash curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \ -H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \ - -d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}' + -d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}' # -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."} ``` -Session scoping is `owner:session`, so an agent can only drive editors owned by the -same user. The bridge is a **browser-only** flow: it requires an editor page to be open. +Session scoping uses the authenticated owner and session as separate values. A +bound editor-tool turn uses its server-owned session, not a model-selected one. +The bridge is a **browser-only** flow: it requires an editor page to be open. +For `/api/editor/agent`, clients must send `incognito: true` for private image +results; omitted or false requests use normal gallery persistence. These +owner-scoped routes do not accept delegated API tokens without a scope contract. --- diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 5a42d3351..8f07fc901 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -3474,7 +3474,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): diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py index 410bab501..dd63ade23 100644 --- a/routes/editor_agent_routes.py +++ b/routes/editor_agent_routes.py @@ -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 +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from src import editor_style_guide as style_guide @@ -1169,16 +1170,66 @@ 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 -def _user(request: Request) -> Optional[str]: +async def _json_object(request: Request) -> Dict[str, Any]: try: - from src.auth_helpers import get_current_user - return get_current_user(request) + 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: + try: + from core.middleware import require_admin + require_admin(request) + return True except Exception: + return False + + +def _user(request: Request) -> Optional[str]: + # These owner-scoped editor APIs have no delegated-token scope contract. + # Do not collapse all bearer users into the shared "api" pseudo-owner. + if getattr(request.state, "api_token", False): + raise HTTPException(403, "API tokens must use a scope-aware API route") + from src.auth_helpers import get_current_user, _auth_disabled + u = get_current_user(request) + if u: + return u + if _auth_disabled(): return None + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr is not None and getattr(auth_mgr, "is_configured", False): + raise HTTPException(401, "Not authenticated") + client = getattr(request, "client", None) + host = (client.host if client else "") or "" + if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host: + return None + raise HTTPException(401, "Not authenticated") async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]: @@ -1206,17 +1257,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) @@ -1253,17 +1301,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") @@ -1363,31 +1424,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: @@ -1412,38 +1491,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 " @@ -1494,11 +1585,14 @@ def setup_editor_agent_routes() -> APIRouter: overwritten. """ owner = _user(request) - try: - body = await request.json() - except Exception: - body = {} - prompt = str(body.get("prompt") or "").strip() + 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") + 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") @@ -1506,10 +1600,10 @@ def setup_editor_agent_routes() -> APIRouter: model_choice = str(body.get("model") or body.get("image_model") or "").strip() # Optional user-picked PLANNER model (the editor agent/planner model). agent_model = str(body.get("agent_model") or "").strip() - tools_offered = _scoped_editor_tools() + 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({ @@ -1601,8 +1695,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({ @@ -1629,24 +1734,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 = { @@ -1664,15 +1775,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: @@ -1683,13 +1796,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: @@ -1726,6 +1842,7 @@ def setup_editor_agent_routes() -> APIRouter: ``edit`` (editing/inpainting models) or ``generate`` (text-to-image). """ owner = _user(request) + is_admin = _is_admin(request) models: List[Dict[str, Any]] = [] seen: set = set() try: @@ -1748,7 +1865,10 @@ def setup_editor_agent_routes() -> APIRouter: # recognisably image-capable — the same test the request # router uses. image_endpoint = getattr(ep, "model_type", None) == "image" - provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image" + provider = getattr(ep, "name", None) + if not provider: + provider = getattr(ep, "base_url", "") if is_admin 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 @@ -1756,14 +1876,16 @@ def setup_editor_agent_routes() -> APIRouter: if key in seen: continue seen.add(key) - models.append({ + entry = { "id": name, "label": name, "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "provider": provider, "endpoint_id": getattr(ep, "id", None), - "base_url": getattr(ep, "base_url", "") or "", - }) + } + if is_admin: + entry["base_url"] = getattr(ep, "base_url", "") or "" + models.append(entry) finally: db.close() except Exception: @@ -1781,35 +1903,45 @@ def setup_editor_agent_routes() -> APIRouter: falls back to). """ owner = _user(request) + 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 = [] + probe_info = {} + if is_admin: + probe_info = editor_agent_model.probe_status() + if not probe_info: + editor_agent_model.startup_probe() + probe_info = editor_agent_model.probe_status() return JSONResponse({ "models": models, - "probe": editor_agent_model.probe_status(), + "probe": probe_info, }) @router.post("/api/editor/self-check") 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, ) diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 294585e79..94f0420ba 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -1827,18 +1827,22 @@ 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_EPOCH = [0] + + 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 +1853,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 +1867,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 +1889,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 +1927,64 @@ 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 _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) @@ -2915,7 +2952,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: diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index c856223c3..f3aa63e5e 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -779,14 +779,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"]) @@ -1842,8 +1845,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: @@ -1978,9 +1981,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 diff --git a/src/agent_tools/editor_tools.py b/src/agent_tools/editor_tools.py index d1fa4176e..aec99a56a 100644 --- a/src/agent_tools/editor_tools.py +++ b/src/agent_tools/editor_tools.py @@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]: def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str: - return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default") + # Explicit editor session bound in context is authoritative; + # otherwise fallback to caller/tool args, then default. + # Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session. + return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default") + + +def _incognito(ctx: Dict[str, Any]) -> bool: + runtime = ctx.get("client_runtime_context") + state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None + return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True) async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]: @@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes: return base64.b64decode(b64) +def _image_reference_path(value: str) -> bool: + # Never use the internal tool credential to fetch an arbitrary app route. + generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value) + upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value) + return bool(generated or upload) + + def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]: """Save an edited/generated PNG and return {image_id, image_url, image_size}.""" from core.database import GalleryImage, SessionLocal from src.constants import GENERATED_IMAGES_DIR db = SessionLocal() + image_path = None try: root = Path(GENERATED_IMAGES_DIR).resolve() root.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}.png" - (root / filename).write_bytes(image_bytes) + image_path = root / filename + image_path.write_bytes(image_bytes) new_id = str(uuid.uuid4()) width = height = 0 try: @@ -163,6 +181,11 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[ return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None} except Exception: db.rollback() + if image_path is not None: + try: + image_path.unlink(missing_ok=True) + except OSError: + logger.warning("Failed to remove uncommitted editor image", exc_info=True) raise finally: db.close() @@ -198,7 +221,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session: } try: data = await _post("/api/editor/bridge/command", payload, owner, timeout=90) - return data.get("ok") is not False + return bool(data.get("ok") is True and data.get("completed", True) is not False) except Exception as exc: # pragma: no cover - defensive logger.warning("editor layer placement failed: %s", exc) return False @@ -367,28 +390,46 @@ class RunEditorCommandTool: args = _parse(content) op = str(args.get("op") or "").strip() if not op: - return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1} + return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False} params = args.get("params") if isinstance(args.get("params"), dict) else {} + wait = bool(args.get("wait", True)) payload = { "session": _session(args, ctx), "op": op, "params": params, - "wait": bool(args.get("wait", True)), + "wait": wait, "timeout": float(args.get("timeout") or 60), } try: data = await _post("/api/editor/bridge/command", payload, ctx.get("owner")) except Exception as exc: - return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1} - ok = data.get("ok") + return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"} + + is_queued = bool(data.get("queued")) or data.get("status") == "queued" + if is_queued and (not wait or not data.get("ok")): + summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False) + return f"run_editor_command: {op}", { + "output": summary, + "queued": data.get("queued"), + "status": "queued", + "pending": True, + "completed": False, + "ok": False, + "exit_code": 1, + } + + ok = bool(data.get("ok") is True and data.get("completed", True) is not False) + status = data.get("status") or ("completed" if ok else "failed") result = data.get("result") - summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False) + summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False) return f"run_editor_command: {op}", { "output": summary[:4000], "result": result, "screenshot": data.get("screenshot"), + "status": status, + "completed": ok, "ok": ok, - "exit_code": 0 if ok is not False else 1, + "exit_code": 0 if ok else 1, } @@ -442,10 +483,14 @@ class AiEditImageTool: image = _as_data_url(args.get("image")) if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"): # A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes. + if not _image_reference_path(args["image"]): + return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1} try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner)) resp.raise_for_status() + if not resp.headers.get("content-type", "").lower().startswith("image/"): + return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1} image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode()) except Exception: image = None @@ -550,10 +595,13 @@ class AiEditImageTool: "model": data.get("model") or model_choice or "", "exit_code": 0, } - try: - info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}")) - except Exception as exc: - logger.warning("ai_edit_image persist failed: %s", exc) + if _incognito(ctx): + info["image_url"] = data_url + else: + try: + info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}")) + except Exception as exc: + logger.warning("ai_edit_image persist failed: %s", exc) _t["persist"] = time.perf_counter() if bool(args.get("place", True)): placed = await _place_layer( @@ -626,10 +674,13 @@ class GenerateImageLayerTool: "model": data.get("image_model") or str(args.get("model") or ""), "exit_code": 0, } - try: - info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image")) - except Exception as exc: - logger.warning("generate_image persist failed: %s", exc) + if _incognito(ctx): + info["image_url"] = data_url + else: + try: + info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image")) + except Exception as exc: + logger.warning("generate_image persist failed: %s", exc) if bool(args.get("place", True)): placed = await _place_layer( data_url, diff --git a/src/editor_agent_model.py b/src/editor_agent_model.py index 88b638b29..138cf27bc 100644 --- a/src/editor_agent_model.py +++ b/src/editor_agent_model.py @@ -341,12 +341,6 @@ async def self_check( def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: cands: List[Dict[str, Any]] = [] - if override: - base, model = _split_override(override) - if model: - cands.append( - {"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"} - ) for base in local_bases(): cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"}) try: @@ -362,6 +356,17 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, break except Exception: pass + if override: + base, model = _split_override(override) + # A request may choose a configured endpoint, never introduce a URL. + # Prefer an exact model match (including hosted credentials) over the + # first local endpoint when a bare model id is supplied. + matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")] + selected = next((c for c in matching if c["model"] == model), None) + if selected is None and model and matching and matching[0]["source"] == "local": + selected = {**matching[0], "model": model} + if selected is not None: + cands.insert(0, {**selected, "source": "override"}) return cands diff --git a/src/editor_style_guide.py b/src/editor_style_guide.py index b6c2e13ca..1355c2608 100644 --- a/src/editor_style_guide.py +++ b/src/editor_style_guide.py @@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile( ) -_RECOLOR_TRAILING_ADJ = re.compile( - r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|" - r"pale|more|less|very|really|slightly|totally|completely)\b\s*$", - re.I, -) +_RECOLOR_TRAILING_ADJECTIVES = frozenset({ + "bright", "brighter", "vivid", "vibrant", "dark", "darker", "light", + "lighter", "deep", "pale", "more", "less", "very", "really", + "slightly", "totally", "completely", +}) def _hex_rgb01(hex_color: str): @@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str: words.pop(0) if len(words) > 1 and words[-1].casefold() in {"to", "into"}: words.pop() + while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES: + words.pop() phrase = " ".join(words) - while phrase and _RECOLOR_TRAILING_ADJ.search(phrase): - phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip() return phrase or "the subject" diff --git a/src/fixture_email.py b/src/fixture_email.py index 4c7cad38c..35d9a8004 100644 --- a/src/fixture_email.py +++ b/src/fixture_email.py @@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N bare = tool.removeprefix("mcp__email__") if bare == "list_email_accounts": - accounts = {} - for index, message in enumerate(messages): - row = _row(index, message) - identity = (row["account_id"], row["account"], row["account_email"]) - accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"], - "email": row["account_email"], "default": not accounts}) return { "accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}], "output": "Fixture mailbox (default)", diff --git a/src/settings.py b/src/settings.py index 3c2938063..411cb6500 100644 --- a/src/settings.py +++ b/src/settings.py @@ -47,9 +47,8 @@ DEFAULT_SETTINGS = { "image_gen_enabled": False, "image_model": "", "image_quality": "medium", - # Local editor-agent/planner model (Quick Edit NL planner). When empty the - # server auto-detects the local Qwen3.5-9B vision server on seneca and falls - # back to the hosted chat model. See src/editor_agent_model.py. + # Optional editor planner/caption endpoints. Empty uses configured chat + # fallback without probing a developer-specific host. "editor_agent_base_url": "", "editor_agent_model": "", "editor_caption_base_url": "", diff --git a/src/tool_capabilities.py b/src/tool_capabilities.py index e99c0c8af..966937655 100644 --- a/src/tool_capabilities.py +++ b/src/tool_capabilities.py @@ -227,8 +227,16 @@ _register( ToolEffect.READ_PRIVATE, ) _register( - {"plan_edit", "run_editor_command"}, + {"plan_edit"}, + ToolEffect.NETWORK_EGRESS, + result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED, +) +_register( + {"run_editor_command"}, + ToolEffect.READ_PRIVATE, + ToolEffect.WRITE_PRIVATE, ToolEffect.UI_SIDE_EFFECT, + result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED, ) _register( { diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 35361e185..9ab653aa9 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1545,7 +1545,7 @@ FUNCTION_TOOL_SCHEMAS = [ "properties": { "op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"}, "params": {"type": "object", "description": "Parameters for the op"}, - "session": {"type": "string", "description": "Editor session id (defaults to the open session)"}, + "session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}, "wait": {"type": "boolean", "description": "Wait for the editor result (default true)"} }, "required": ["op"] @@ -1560,7 +1560,7 @@ FUNCTION_TOOL_SCHEMAS = [ "parameters": { "type": "object", "properties": { - "session": {"type": "string", "description": "Editor session id (defaults to the open session)"} + "session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"} } } } @@ -1583,7 +1583,7 @@ FUNCTION_TOOL_SCHEMAS = [ "steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."}, "place": {"type": "boolean", "description": "Place the result as a new layer (default true)"}, "model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"}, - "session": {"type": "string", "description": "Editor session id"} + "session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"} }, "required": ["op"] } @@ -2460,4 +2460,4 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock else: content = json.dumps(args) - return ToolBlock(tool_type, content) \ No newline at end of file + return ToolBlock(tool_type, content) diff --git a/src/turn_contract.py b/src/turn_contract.py index 7b56ea73d..e1cbd305c 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -2997,18 +2997,6 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None) return RequiredReadOperation(tool, {"action": action} if action else {}, maximum) -def note_read_intent(user_text: str) -> tuple[str, bool]: - """Return a quoted mention query and whether the note body is needed.""" - text = str(user_text or "") - mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I) - query = mention.group(2).strip() if mention else "" - needs_body = bool( - re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I) - or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I) - ) - return query, needs_body - - def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None: """Resolve complete list/read requests, or repeat an exact prior read. diff --git a/tests/test_agent_editor_routes.py b/tests/test_agent_editor_routes.py index 16b40cfb3..89f0beb59 100644 --- a/tests/test_agent_editor_routes.py +++ b/tests/test_agent_editor_routes.py @@ -15,7 +15,9 @@ from routes import editor_agent_routes as eaar @pytest.fixture(autouse=True) -def _reset_planner(): +def _reset_planner(monkeypatch): + # Minimal router fixtures have no authentication middleware. + monkeypatch.setenv("AUTH_ENABLED", "false") eaar.set_editor_planner(None) yield eaar.set_editor_planner(None) @@ -161,6 +163,22 @@ def test_bridge_command_accepts_selection_export_mask(client): assert ok["queued"], ok +@pytest.mark.parametrize("path", ["/api/editor/plan", "/api/editor/agent", "/api/editor/bridge/command", + "/api/editor/bridge/result", "/api/editor/bridge/await", "/api/editor/self-check"]) +def test_editor_routes_reject_non_object_json(client, path): + assert client.post(path, json=[]).status_code == 400 + + +@pytest.mark.parametrize("path", ["/api/editor/plan", "/api/editor/agent"]) +def test_editor_routes_bound_prompt_before_planning(client, path): + assert client.post(path, json={"prompt": "x" * 4097}).status_code == 413 + + +@pytest.mark.parametrize("value", ["abc", "NaN", "Infinity", -1, 901]) +def test_bridge_rejects_invalid_timeout(client, value): + assert client.post("/api/editor/bridge/command", json={"op": "screenshot", "timeout": value}).status_code == 400 + + def test_set_editor_planner_is_used_by_setup_when_set(): sentinel = lambda messages, owner: None # noqa: E731 eaar.set_editor_planner(sentinel) @@ -838,6 +856,225 @@ def test_bridge_roundtrip_subscribe_command_result(): asyncio.run(scenario()) +def test_bridge_rejects_result_from_other_owner(monkeypatch): + import asyncio + from types import SimpleNamespace + from httpx import ASGITransport, AsyncClient + + monkeypatch.setattr(eaar, "_user", lambda request: request.headers.get("x-test-owner")) + monkeypatch.setattr(eaar.editor_agent_model, "startup_probe", lambda: None) + monkeypatch.setattr(eaar.uuid, "uuid4", lambda: SimpleNamespace(hex="known-command-id")) + app = FastAPI() + app.include_router(eaar.setup_editor_agent_routes()) + + async def scenario(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + pending = asyncio.create_task(client.post( + "/api/editor/bridge/command", headers={"x-test-owner": "alice"}, + json={"session": "s", "op": "screenshot", "wait": True, "timeout": 2}, + )) + await asyncio.sleep(0.05) + payload = {"id": "known-command-id", "ok": True, "result": {"value": "legitimate"}} + denied = await client.post("/api/editor/bridge/result", headers={"x-test-owner": "bob"}, json=payload) + assert denied.status_code == 404 + denied_await = await client.post("/api/editor/bridge/await", headers={"x-test-owner": "bob"}, json=payload) + assert denied_await.status_code == 404 + accepted = await client.post("/api/editor/bridge/result", headers={"x-test-owner": "alice"}, json=payload) + assert accepted.status_code == 200 + assert (await pending).json()["result"] == {"value": "legitimate"} + + asyncio.run(scenario()) + + +def test_bridge_session_keys_do_not_collide_on_colons(monkeypatch): + import asyncio + + monkeypatch.setattr(eaar, "_user", lambda request: request.headers.get("x-owner")) + monkeypatch.setattr(eaar.editor_agent_model, "startup_probe", lambda: None) + app = FastAPI() + app.include_router(eaar.setup_editor_agent_routes()) + + async def scenario(): + received = [] + stop = asyncio.Event() + + async def receive(): + await stop.wait() + return {"type": "http.disconnect"} + + async def send(message): + if message["type"] == "http.response.body": + received.append(message.get("body", b"")) + + subscriber = asyncio.create_task(app({ + "type": "http", "method": "GET", "path": "/api/editor/bridge/events", + "query_string": b"session=b%3Ac", "headers": [(b"x-owner", b"a")], + }, receive, send)) + for _ in range(100): + if any(b"connected" in item for item in received): + break + await asyncio.sleep(0.01) + from httpx import ASGITransport, AsyncClient + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + await client.post("/api/editor/bridge/command", headers={"x-owner": "a:b"}, + json={"session": "c", "op": "screenshot"}) + await asyncio.sleep(0.05) + assert not any(b"data:" in item for item in received) + await client.post("/api/editor/bridge/command", headers={"x-owner": "a"}, + json={"session": "b:c", "op": "document.info"}) + for _ in range(100): + if any(b"document.info" in item for item in received): + break + await asyncio.sleep(0.01) + assert any(b"document.info" in item for item in received) + stop.set() + await asyncio.wait_for(subscriber, 2) + + asyncio.run(scenario()) + + +def test_bridge_no_wait_result_can_be_awaited_after_it_arrives(client): + queued = client.post("/api/editor/bridge/command", json={ + "session": "s", "op": "document.info", "wait": False, + }).json() + command_id = queued["queued"][0] + client.post("/api/editor/bridge/result", json={"id": command_id, "ok": True, "result": {"width": 10}}) + result = client.post("/api/editor/bridge/await", json={"id": command_id, "timeout": 0.01}).json() + assert result.get("result") == {"width": 10} + + +def test_bridge_multiple_results_remain_independent(client): + queued = client.post("/api/editor/bridge/command", json={ + "session": "s", "steps": [{"op": "document.info"}, {"op": "screenshot"}], "wait": True, + }).json()["queued"] + assert len(queued) == 2 + for command_id, value in zip(reversed(queued), (2, 1)): + client.post("/api/editor/bridge/result", json={"id": command_id, "ok": True, "result": {"value": value}}) + for command_id, value in zip(queued, (1, 2)): + result = client.post("/api/editor/bridge/await", json={"id": command_id, "timeout": 0.1}).json() + assert result["result"] == {"value": value} + + +def test_timed_out_bridge_command_is_not_replayed(): + import asyncio + from httpx import ASGITransport, AsyncClient + app = _bridge_app() + + async def scenario(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/api/editor/bridge/command", json={ + "session": "late", "op": "text.add", "wait": True, "timeout": 0.01, + }) + assert response.json()["error"] == "timeout" + received = [] + stop = asyncio.Event() + + async def receive(): + await stop.wait() + return {"type": "http.disconnect"} + + async def send(message): + if message["type"] == "http.response.body": + received.append(message.get("body", b"")) + + subscriber = asyncio.create_task(app({"type": "http", "method": "GET", + "path": "/api/editor/bridge/events", "query_string": b"session=late", "headers": []}, receive, send)) + await asyncio.sleep(0.05) + await client.post("/api/editor/bridge/command", json={"session": "late", "op": "document.info"}) + await asyncio.sleep(0.05) + assert any(b"document.info" in item for item in received) + assert not any(b"text.add" in item for item in received) + stop.set() + await asyncio.wait_for(subscriber, 2) + + asyncio.run(scenario()) + + +def test_cancelled_bridge_command_is_not_pending(monkeypatch): + import asyncio + from types import SimpleNamespace + from httpx import ASGITransport, AsyncClient + + monkeypatch.setattr(eaar.uuid, "uuid4", lambda: SimpleNamespace(hex="cancelled-id")) + app = _bridge_app() + + async def scenario(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + task = asyncio.create_task(client.post("/api/editor/bridge/command", json={ + "session": "late", "op": "text.add", "wait": True, "timeout": 2, + })) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + result = await client.post("/api/editor/bridge/result", json={"id": "cancelled-id", "ok": True}) + assert result.json() == {"ok": True} + + asyncio.run(scenario()) + + +def test_editor_agent_denies_image_generation_without_privilege(monkeypatch): + class Auth: + is_configured = True + + def get_privileges(self, user): + return {"can_generate_images": False} + + app = FastAPI() + app.state.auth_manager = Auth() + + @app.middleware("http") + async def identity(request, call_next): + request.state.current_user = "alice" + return await call_next(request) + + app.include_router(eaar.setup_editor_agent_routes()) + eaar.set_editor_planner(lambda messages, owner: { + "reply": "generate", "steps": [{"op": "generate_image", "params": {"prompt": "a tree"}}] + }) + monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True) + with TestClient(app) as client: + response = client.post("/api/editor/agent", json={"prompt": "generate an image of a tree"}) + assert response.status_code == 403 + + +def test_editor_owner_routes_reject_delegated_token(): + app = FastAPI() + + @app.middleware("http") + async def identity(request, call_next): + request.state.current_user = "api" + request.state.api_token = True + request.state.api_token_owner = "alice" + return await call_next(request) + + app.include_router(eaar.setup_editor_agent_routes()) + with TestClient(app) as client: + for path in ("/api/editor/plan", "/api/editor/bridge/command", "/api/editor/agent"): + assert client.post(path, json={"prompt": "hello"}).status_code == 403 + + +def test_editor_tool_session_uses_bound_turn_not_model_arguments(): + from src.agent_tools.editor_tools import _session + args = {"session": "other-editor", "editor_session": "also-other"} + # Explicit editor_session bound in ctx takes precedence over args + assert _session(args, {"editor_session": "authorized-editor"}) == "authorized-editor" + # When no explicit editor_session is bound, tool/caller argument is used (chat turns have chat session_id) + assert _session(args, {"session_id": "chat-uuid"}) == "other-editor" + assert _session({}, {}) == "default" + + +def test_editor_agent_honors_disabled_image_tool_before_dispatch(client, monkeypatch): + import src.settings as settings + from src.agent_tools import editor_tools + + monkeypatch.setattr(settings, "get_setting", lambda name, default=None: ["ai_edit_image"] if name == "disabled_tools" else default) + monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True) + monkeypatch.setattr(editor_tools.AiEditImageTool, "execute", lambda *args: pytest.fail("disabled tool ran")) + eaar.set_editor_planner(lambda messages, owner: {"steps": [{"op": "ai_edit_image", "params": {"op": "denoise"}}]}) + assert client.post("/api/editor/agent", json={"prompt": "do this"}).status_code == 403 + + def test_bridge_broadcasts_to_every_subscriber(): """URGENT regression: with two live subscribers on the same session (EventSource reconnects overlap), a command must reach BOTH, never be eaten by one.""" @@ -943,6 +1180,129 @@ def test_ai_edit_image_tool_forwards_and_reports_model(monkeypatch): assert res.get("model") == "qwen-image-edit-2511", res +def test_ai_editor_tool_incognito_does_not_persist(monkeypatch): + import asyncio + import base64 + import io + from PIL import Image + import src.agent_tools.editor_tools as et + + buf = io.BytesIO() + Image.new("RGB", (32, 32), (10, 20, 30)).save(buf, "PNG") + image = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + saved = [] + + async def fake_post(*args, **kwargs): + return {"image": image} + + monkeypatch.setattr(et, "_post", fake_post) + monkeypatch.setattr(et, "_persist_png", lambda *args: saved.append(args) or {}) + _, result = asyncio.run(et.AiEditImageTool().execute( + {"op": "denoise", "image": image, "place": False}, + {"owner": "u", "client_runtime_context": {"web_ui_state": {"incognito": True}}}, + )) + assert result["exit_code"] == 0 + assert not saved + assert result["image_url"] == image + + +def test_ai_editor_tool_rejects_unrelated_internal_url(monkeypatch): + import asyncio + import src.agent_tools.editor_tools as et + + class NeverFetch: + def __init__(self, *args, **kwargs): + pytest.fail("unrelated internal route must not be fetched") + + monkeypatch.setattr(et.httpx, "AsyncClient", NeverFetch) + _, result = asyncio.run(et.AiEditImageTool().execute( + {"op": "denoise", "image": "/api/email/messages?folder=INBOX", "place": False}, + {"owner": "alice", "session_id": "authorized"}, + )) + assert result["exit_code"] == 1 + + +def test_ai_editor_tool_accepts_owned_generated_image_reference(monkeypatch): + import asyncio + import base64 + import io + from PIL import Image + import src.agent_tools.editor_tools as et + + buf = io.BytesIO() + Image.new("RGB", (16, 16), (10, 20, 30)).save(buf, "PNG") + image = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + seen = [] + + class Client: + def __init__(self, *args, **kwargs): pass + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + async def get(self, url, **kwargs): + seen.append(url) + return __import__('httpx').Response(200, content=buf.getvalue(), + headers={"content-type": "image/png"}, request=__import__('httpx').Request('GET', url)) + + async def fake_post(*args, **kwargs): return {"image": image} + monkeypatch.setattr(et.httpx, "AsyncClient", Client) + monkeypatch.setattr(et, "_post", fake_post) + _, result = asyncio.run(et.AiEditImageTool().execute( + {"op": "denoise", "image": "/api/generated-image/abcdef123456.png", "place": False}, + {"owner": "alice", "session_id": "authorized", "incognito": True}, + )) + assert result["exit_code"] == 0 + assert seen and seen[0].endswith('/api/generated-image/abcdef123456.png') + + +def test_generate_editor_tool_incognito_does_not_persist(monkeypatch): + import asyncio + import src.agent_tools.editor_tools as et + import src.ai_interaction as ai + + image = "data:image/png;base64,YWJj" + saved = [] + + async def fake_generate(*args, **kwargs): + assert kwargs["incognito"] is True + return {"image_url": image} + + monkeypatch.setattr(et, "_provider_available", lambda owner: True) + monkeypatch.setattr(et, "_persist_png", lambda *args: saved.append(args) or {}) + monkeypatch.setattr(ai, "do_generate_image", fake_generate) + _, result = asyncio.run(et.GenerateImageLayerTool().execute( + json.dumps({"prompt": "a tree", "place": False}), + {"owner": "u", "incognito": True}, + )) + assert result["exit_code"] == 0 + assert not saved + assert result["image_url"] == image + + +def test_editor_image_is_removed_if_gallery_commit_fails(monkeypatch, tmp_path): + import core.database as db_module + import src.constants as constants + import src.agent_tools.editor_tools as et + + class FailingSession: + def add(self, row): + pass + + def commit(self): + raise RuntimeError("commit failed") + + def rollback(self): + pass + + def close(self): + pass + + monkeypatch.setattr(db_module, "SessionLocal", FailingSession) + monkeypatch.setattr(constants, "GENERATED_IMAGES_DIR", str(tmp_path)) + with pytest.raises(RuntimeError, match="commit failed"): + et._persist_png(b"test image", "alice", None, "test", "test") + assert list(tmp_path.iterdir()) == [] + + def test_agent_path_injects_selected_model_into_ai_step(client, monkeypatch): """The /api/editor/agent body `model` reaches the AI step tool payload.""" monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True) @@ -973,6 +1333,32 @@ def test_agent_path_injects_selected_model_into_ai_step(client, monkeypatch): assert data["results"][-1]["result"].get("model") == "qwen-image-edit-2511", data +def test_agent_request_controls_session_model_and_screenshot(client, monkeypatch): + from src.agent_tools import editor_tools + captured = {} + + class Capture: + async def execute(self, content, ctx): + captured.update(json.loads(content)) + return "ai_edit_image", {"exit_code": 1, "error": "stopped"} + + monkeypatch.setattr(editor_tools, "AiEditImageTool", Capture) + monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True) + eaar.set_editor_planner(lambda messages, owner: {"steps": [{"op": "ai_edit_image", "params": { + "op": "generative", "session": "other", "model": "other", "image": "other" + }}]}) + response = client.post("/api/editor/agent", json={"prompt": "do this", "session": "current", + "model": "chosen", "screenshot": "data:image/png;base64,AAAA"}) + assert response.status_code == 200 + assert captured["session"] == "current" + assert captured["model"] == "chosen" + assert captured["image"] == "data:image/png;base64,AAAA" + captured.clear() + response = client.post("/api/editor/agent", json={"prompt": "do this", "session": "current"}) + assert response.status_code == 200 + assert "image" not in captured + + # --------------------------------------------------------------------------- # Editor agent/planner model (local Qwen3.5-9B) resolution + reporting. # --------------------------------------------------------------------------- @@ -1043,8 +1429,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch): monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"]) monkeypatch.setattr(eam, "local_model", lambda: "LMODEL") - cands = eam._candidates(None, "http://ovr::OVERRIDE") - assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None, + cands = eam._candidates(None, "http://local1::OVERRIDE") + assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None, "source": "override"} assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands) @@ -1492,3 +1878,110 @@ def test_editor_agent_plan_response_format_only_sent_to_local(monkeypatch): assert calls and calls[0][0] == "http://100.69.120.65:8097/v1" assert calls[0][1] is fmt assert all(base != "https://api.deepseek.com/v1" for base, _ in calls) + + +def test_run_editor_command_queued_never_reports_success(monkeypatch): + import asyncio + from src.agent_tools import editor_tools + + async def fake_post(path, payload, owner, timeout=60): + return {"queued": ["cmd-123"], "session": "anon:default", "status": "queued", "completed": False, "ok": False} + + monkeypatch.setattr(editor_tools, "_post", fake_post) + tool = editor_tools.RunEditorCommandTool() + label, result = asyncio.run(tool.execute(json.dumps({"op": "document.info", "wait": False}), {})) + + assert result["status"] == "queued" + assert result["completed"] is False + assert result["ok"] is False + assert result["exit_code"] == 1 + assert result["pending"] is True + + +def test_run_editor_command_completed_reports_success(monkeypatch): + import asyncio + from src.agent_tools import editor_tools + + async def fake_post(path, payload, owner, timeout=60): + return {"ok": True, "status": "completed", "completed": True, "result": {"info": "ok"}} + + monkeypatch.setattr(editor_tools, "_post", fake_post) + tool = editor_tools.RunEditorCommandTool() + label, result = asyncio.run(tool.execute(json.dumps({"op": "document.info", "wait": True}), {})) + + assert result["status"] == "completed" + assert result["completed"] is True + assert result["ok"] is True + assert result["exit_code"] == 0 + assert result["result"] == {"info": "ok"} + + +def test_place_layer_reports_false_on_queued_response(monkeypatch): + import asyncio + from src.agent_tools import editor_tools + + async def fake_post(path, payload, owner, timeout=90): + return {"queued": ["place-123"], "session": "anon:default", "status": "queued", "completed": False, "ok": False} + + monkeypatch.setattr(editor_tools, "_post", fake_post) + placed = asyncio.run(editor_tools._place_layer("data:image/png;base64,abc", "layer1", "owner", "default")) + assert placed is False + + +def test_bridge_await_cancellation_handled_cleanly(): + import asyncio + from httpx import ASGITransport, AsyncClient + app = _bridge_app() + + async def scenario(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + cmd_resp = await c.post("/api/editor/bridge/command", json={"session": "cancel-sess", "op": "document.info", "wait": False}) + cid = cmd_resp.json()["queued"][0] + + await_task = asyncio.create_task(c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 30})) + await asyncio.sleep(0.05) + await_task.cancel() + try: + await await_task + except asyncio.CancelledError: + pass + + asyncio.run(scenario()) + + +def test_unauthenticated_editor_routes_fail_closed_when_auth_configured(client, monkeypatch): + from types import SimpleNamespace + monkeypatch.setenv("AUTH_ENABLED", "true") + client.app.state.auth_manager = SimpleNamespace(is_configured=True) + r = client.post("/api/editor/bridge/command", json={"op": "document.info"}) + assert r.status_code == 401 + + +def test_auth_disabled_mode_allows_single_user_access(client, monkeypatch): + import src.auth_helpers as auth_helpers + monkeypatch.setattr(auth_helpers, "_auth_disabled", lambda: True) + r = client.post("/api/editor/bridge/command", json={"op": "document.info", "wait": False}) + assert r.status_code == 200 + + +def test_editor_models_redact_base_url_for_non_admins(client, monkeypatch): + import core.middleware as core_middleware + from fastapi import HTTPException + + def fake_require_admin(request): + raise HTTPException(403, "Admin required") + + monkeypatch.setattr(core_middleware, "require_admin", fake_require_admin) + + r_agent = client.get("/api/editor/agent-models") + assert r_agent.status_code == 200 + agent_data = r_agent.json() + assert agent_data.get("probe") == {} + for m in agent_data.get("models", []): + assert "base_url" not in m + + r_img = client.get("/api/editor/image-models") + assert r_img.status_code == 200 + img_data = r_img.json() + for m in img_data.get("models", []): + assert "base_url" not in m diff --git a/tests/test_editor_agent_model_resolution.py b/tests/test_editor_agent_model_resolution.py index 35d6bd7a8..b258e3e59 100644 --- a/tests/test_editor_agent_model_resolution.py +++ b/tests/test_editor_agent_model_resolution.py @@ -81,3 +81,11 @@ def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch): assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter")) assert result["reason"] == "unavailable" + + +def test_unconfigured_or_untrusted_override_never_becomes_endpoint(monkeypatch): + monkeypatch.setattr(eam, "local_bases", lambda: []) + import src.endpoint_resolver as resolver + monkeypatch.setattr(resolver, "resolve_endpoint", lambda *a, **k: (None, None, None)) + assert eam._candidates("alice", "chosen-model") == [] + assert eam._candidates("alice", "http://169.254.169.254::model") == [] diff --git a/tests/test_email_list_stale_while_revalidate.py b/tests/test_email_list_stale_while_revalidate.py index 559994a10..de9594ae6 100644 --- a/tests/test_email_list_stale_while_revalidate.py +++ b/tests/test_email_list_stale_while_revalidate.py @@ -67,6 +67,66 @@ def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatc assert not (result.get("sync") or {}).get("stale") +def test_manual_refresh_does_not_serve_index_snapshot(monkeypatch): + endpoint = _list_endpoint() + monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([{"uid": "11"}], 1, "now")) + attempts = [] + + def offline(*args, **kwargs): + attempts.append(True) + raise RuntimeError("offline test") + + monkeypatch.setattr(er, "_imap_connect", offline) + result = _call(endpoint, refresh=1) + assert attempts + assert not (result.get("sync") or {}).get("stale") + + +def test_list_invalidation_discards_stale_snapshot(monkeypatch): + endpoint = _list_endpoint() + state = dict(zip(endpoint.__code__.co_freevars, (cell.cell_contents for cell in endpoint.__closure__))) + key = ("account", "INBOX", "all", 50, 0, "", 0, "", "", "alice") + state["_list_cache_put"](key, {"emails": [{"uid": "11"}]}) + state["_invalidate_list_cache"]("account", "INBOX") + monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None)) + monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline"))) + result = _call(endpoint, account_id="account", owner="alice") + assert not (result.get("sync") or {}).get("stale") + + +def test_invalidation_prevents_inflight_refresh_from_restoring_stale_snapshot(): + import threading + import time + endpoint = _list_endpoint() + state = dict(zip(endpoint.__code__.co_freevars, (cell.cell_contents for cell in endpoint.__closure__))) + schedule = state["_schedule_list_refresh"] + cells = dict(zip(schedule.__code__.co_freevars, schedule.__closure__)) + started, release = threading.Event(), threading.Event() + original = cells["_list_emails_sync"].cell_contents + + def fake_fetch(*args): + started.set() + assert release.wait(3) + return {"emails": [{"uid": "stale"}]} + + cells["_list_emails_sync"].cell_contents = fake_fetch + key = ("account", "INBOX", "all", 50, 0, "", 0, "", "", "alice") + try: + schedule((), key, "INBOX", "account", "alice") + assert started.wait(3) + state["_invalidate_list_cache"]("account", "INBOX") + release.set() + refreshing = cells["_LIST_REFRESHING"].cell_contents + deadline = time.monotonic() + 3 + while key in refreshing and time.monotonic() < deadline: + time.sleep(0.01) + assert key not in refreshing + assert state["_list_stale_get"](key) is None + finally: + release.set() + cells["_list_emails_sync"].cell_contents = original + + def test_swr_contract_is_wired_into_the_list_path(): assert "_LIST_STALE" in SRC assert "def _list_stale_put" in SRC diff --git a/tests/test_gallery_cloud_inpaint.py b/tests/test_gallery_cloud_inpaint.py index 0bfbd7ffe..af0e635b2 100644 --- a/tests/test_gallery_cloud_inpaint.py +++ b/tests/test_gallery_cloud_inpaint.py @@ -16,7 +16,9 @@ def png(image): return base64.b64encode(output.getvalue()).decode() -def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch): +def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog): + import logging + caplog.set_level(logging.DEBUG) endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test') class Db: @@ -46,7 +48,7 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch): class Request: async def json(self): return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image', - 'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'Make blue'} + 'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'} route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint') result = asyncio.run(route.endpoint(Request())) @@ -54,6 +56,7 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch): assert output.getpixel((0, 0)) == (255, 0, 0, 255) assert output.getpixel((1, 0)) == (0, 0, 255, 255) assert len(requests) == 1 + assert 'private-cloud-prompt' not in caplog.text def test_mixed_endpoint_requires_configured_image_model(monkeypatch): @@ -165,6 +168,19 @@ def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch): assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480) +def test_inpaint_does_not_log_private_prompt_at_debug_level(monkeypatch, caplog): + import logging + caplog.set_level(logging.DEBUG) + secret = 'private-prompt-log-regression' + + def respond(request): + return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (4, 4), 'blue'))}]}) + + _self_hosted_inpaint(monkeypatch, Image.new('RGB', (4, 4), 'red'), + Image.new('L', (4, 4), 255), {'prompt': secret}, respond) + assert secret not in caplog.text + + def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch): """A whole-image instruction edit may omit the mask; it must not raise KeyError -> 400 (the health-audit regression).""" diff --git a/tests/test_gallery_seneca_image_ops.py b/tests/test_gallery_seneca_image_ops.py index ae1759a08..1357a7932 100644 --- a/tests/test_gallery_seneca_image_ops.py +++ b/tests/test_gallery_seneca_image_ops.py @@ -129,6 +129,29 @@ def test_replace_background_502_when_provider_returns_no_image(monkeypatch): raise AssertionError("expected a 502 when the provider returns no image") +def test_provider_timeout_is_reported_as_gateway_timeout(monkeypatch): + def timeout(_request): + raise httpx.ReadTimeout("local test timeout") + + _setup(monkeypatch, timeout) + try: + asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64}))) + except Exception as exc: + assert getattr(exc, "status_code", None) == 504 + else: + raise AssertionError("expected a 504 for a provider timeout") + + +def test_malformed_provider_reply_is_a_gateway_error(monkeypatch): + _setup(monkeypatch, lambda _req: httpx.Response(200, content=b'not-json')) + try: + asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64}))) + except Exception as exc: + assert getattr(exc, "status_code", None) == 502 + else: + raise AssertionError("expected a 502 for malformed provider JSON") + + async def _async_return(value): return value diff --git a/tests/test_photo_editor_style_guide.py b/tests/test_photo_editor_style_guide.py index 6dfde4077..e38510192 100644 --- a/tests/test_photo_editor_style_guide.py +++ b/tests/test_photo_editor_style_guide.py @@ -22,6 +22,14 @@ def test_recolor_phrase_cleanup_handles_long_whitespace(prompt, target, expected assert guide._recolor_object_phrase(prompt, target) == expected +def test_recolor_phrase_cleanup_handles_many_trailing_adjectives(): + # Repeated regex substitutions previously rescanned the whole string for + # every adjective; this input would take minutes rather than linear time. + prompt = 'make the red car blue ' + 'bright ' * 100_000 + assert guide._recolor_object_phrase(prompt, 'blue') == 'red car' + assert guide._recolor_object_phrase('make bright', '') == 'bright' + + @pytest.fixture(autouse=True) def _reset_planner(): eaar.set_editor_planner(None)