Merge branch 'dev' into review/pewds-pr6738-terra-20261027

Integrate PR #6739 foundation (backend security, bridge hardening, Python 3.11 test fixture correction) with PR #6738 remediations.
- Reconcile routes/email/email_routes.py harmonizing _LIST_LOCK and _LIST_EPOCH with #6738 cache invalidation and thread-safe mutation.
- Preserve editor-session validation, legacy-browser tool restrictions, image-tool authorization, and email cache concurrency invariants.
This commit is contained in:
Alexandre Teixeira
2026-10-27 18:12:05 +00:00
19 changed files with 974 additions and 169 deletions
+11 -4
View File
@@ -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; `<session>` = `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.
---
+1 -1
View File
@@ -3526,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):
+216 -84
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
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,
)
+76 -22
View File
@@ -1827,21 +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_CACHE_LOCK = _threading.RLock()
_LIST_LOCK = _threading.RLock()
_LIST_CACHE_LOCK = _LIST_LOCK
# Invalidation fences reads already in flight, including background refresh.
_LIST_CACHE_GENERATION = [0]
_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
@@ -1852,8 +1856,9 @@ def setup_email_routes():
_LIST_REFRESHING = set()
def _list_cache_put(key, value):
with _LIST_CACHE_LOCK:
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]:
@@ -1865,18 +1870,18 @@ 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.
"""
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
generation = _LIST_CACHE_GENERATION[0]
epoch = _LIST_EPOCH[0]
def _run():
try:
refreshed = _list_emails_sync(*sync_args)
if refreshed and not refreshed.get("error"):
with _LIST_CACHE_LOCK:
if generation != _LIST_CACHE_GENERATION[0]:
with _LIST_LOCK:
if epoch != _LIST_EPOCH[0]:
return
_list_cache_put(cache_key, refreshed)
try:
@@ -1887,7 +1892,7 @@ def setup_email_routes():
except Exception as exc: # pragma: no cover - background best effort
logger.debug("email list background refresh failed: %s", exc)
finally:
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
_LIST_REFRESHING.discard(cache_key)
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
@@ -1925,10 +1930,14 @@ def setup_email_routes():
hook(account_id, folder)
except Exception:
pass
with _LIST_CACHE_LOCK:
_LIST_CACHE_GENERATION[0] += 1
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):
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 \
@@ -1936,15 +1945,60 @@ def setup_email_routes():
cache.pop(k, None)
def _index_update_flags(owner, account_id, folder, uid, flag, add):
with _LIST_CACHE_LOCK:
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_CACHE_LOCK:
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)
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
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)
if not v: return None
+9 -6
View File
@@ -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
+68 -17
View File
@@ -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,
+11 -6
View File
@@ -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
+7 -7
View File
@@ -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"
-6
View File
@@ -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)",
+2 -3
View File
@@ -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": "",
+9 -1
View File
@@ -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(
{
+3 -3
View File
@@ -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"]
}
+498 -4
View File
@@ -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)
@@ -25,7 +27,8 @@ def _reset_planner():
def client():
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return TestClient(app)
with TestClient(app) as client:
yield client
def test_plan_uses_injected_planner_and_parses_steps(client):
@@ -161,6 +164,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 +857,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 +1181,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 +1334,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 +1430,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 +1879,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
+1
View File
@@ -83,6 +83,7 @@ def _auth_regressions_stubs(monkeypatch):
build_chat_url=MagicMock(),
build_models_url=MagicMock(),
build_headers=MagicMock(),
resolve_owner_registered_endpoint_url=MagicMock(),
)
# _ensure_stub now registers each stub through monkeypatch itself, so the
# whole set is undone at teardown. Re-setting them here would capture the
@@ -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") == []
+18 -2
View File
@@ -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)."""
+23
View File
@@ -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
+5 -3
View File
@@ -322,12 +322,14 @@ def test_image_provider_respects_disabled_setting(monkeypatch):
assert image_editing_provider_available(None) is False
def _agent_client():
def _agent_client(monkeypatch=None):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.editor_agent_routes as eaar
if monkeypatch is not None:
monkeypatch.setenv("AUTH_ENABLED", "false")
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return eaar, TestClient(app)
@@ -339,7 +341,7 @@ def _agent_client():
)
def test_editor_agent_blocks_generative_without_provider(monkeypatch, prompt):
"""No image provider -> zero canvas changes + a plain provider message."""
eaar, client = _agent_client()
eaar, client = _agent_client(monkeypatch)
# Planner proposes an unrelated deterministic edit, as the buggy run did.
eaar.set_editor_planner(
lambda messages, owner: {"reply": "done", "steps": [
@@ -370,7 +372,7 @@ def test_editor_agent_blocks_generative_without_provider(monkeypatch, prompt):
def test_editor_agent_prefers_ai_op_over_deterministic_when_provider_present(monkeypatch):
eaar, client = _agent_client()
eaar, client = _agent_client(monkeypatch)
eaar.set_editor_planner(
lambda messages, owner: {"reply": "done", "steps": [
{"op": "selection.rectangle", "params": {}},
+8
View File
@@ -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)