mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 00:32:22 +01:00
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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": "",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user