mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-02 03:02:28 +01:00
merge(dev): reconcile photo editor security and upstream changes
- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py - Enforce strict server-authorized endpoint resolution and reject client URL overrides - Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata - Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues - Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357 - Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression - Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
This commit is contained in:
+224
-91
@@ -21,11 +21,12 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from src import editor_style_guide as style_guide
|
||||
@@ -1169,17 +1170,90 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
op = str(step.get("op") or "")
|
||||
if op not in _OP_SET:
|
||||
continue
|
||||
out.append({"op": op, "params": step.get("params") or {}})
|
||||
params = step.get("params")
|
||||
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||
return out
|
||||
|
||||
|
||||
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Expected a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _prompt(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > 4096:
|
||||
raise HTTPException(413, "Editor prompt is too long")
|
||||
return text
|
||||
|
||||
|
||||
def _bridge_timeout(value: Any) -> float:
|
||||
try:
|
||||
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
||||
raise HTTPException(400, "Invalid bridge timeout")
|
||||
return timeout
|
||||
|
||||
|
||||
def _is_admin(request: Request) -> bool:
|
||||
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
|
||||
|
||||
Delegated credentials (bearer API tokens) are explicitly rejected:
|
||||
per src/tool_security.py, bearer tokens are long-lived credentials handed to
|
||||
third parties/scripts and must remain capped at non-admin policies even
|
||||
when minted by an administrator.
|
||||
"""
|
||||
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
|
||||
if getattr(request.state, "api_token", False):
|
||||
return False
|
||||
|
||||
# 2. In-process internal tool bypass (loopback agent tools)
|
||||
try:
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||
import secrets
|
||||
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||
return True
|
||||
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
|
||||
try:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if _auth_disabled():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Middleware require_admin
|
||||
try:
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
pass
|
||||
|
||||
# 5. Direct session/user check when request.state.current_user is set
|
||||
try:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr and getattr(auth_mgr, "is_configured", False):
|
||||
from src.auth_helpers import get_current_user
|
||||
user = get_current_user(request) or getattr(request.state, "current_user", None)
|
||||
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _user(request: Request) -> Optional[str]:
|
||||
@@ -1187,7 +1261,6 @@ def _user(request: Request) -> Optional[str]:
|
||||
if getattr(request.state, "api_token", False):
|
||||
from src.auth_helpers import require_api_token_scope
|
||||
return require_api_token_scope(request, "chat")
|
||||
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
@@ -1229,17 +1302,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if _EDITOR_PLANNER is None:
|
||||
set_editor_planner(_default_editor_planner)
|
||||
|
||||
# Probe the local editor-agent model server (non-blocking) so the planner
|
||||
# can use it for vision planning when reachable.
|
||||
editor_agent_model.startup_probe()
|
||||
|
||||
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
||||
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
||||
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
||||
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[str, List[dict]] = {}
|
||||
pending: Dict[str, asyncio.Future] = {}
|
||||
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||
max_pending = 1024
|
||||
|
||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||
@@ -1276,17 +1346,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
del bl[0: len(bl) - _QUEUE_MAX]
|
||||
return 0
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
||||
return f"{owner or 'anon'}:{session or 'default'}"
|
||||
def _expire_command(session, cid, fut):
|
||||
entry = pending.get(cid)
|
||||
if entry is not None and entry[1] is fut:
|
||||
pending.pop(cid, None)
|
||||
if not fut.done():
|
||||
fut.cancel()
|
||||
queued = backlog.get(session)
|
||||
if queued:
|
||||
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
|
||||
if not backlog[session]:
|
||||
backlog.pop(session, None)
|
||||
|
||||
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
|
||||
# Usernames and session ids may both contain colons.
|
||||
return (owner, str(session or 'default'))
|
||||
|
||||
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
|
||||
return f"{scope[0] or 'anon'}:{scope[1]}"
|
||||
|
||||
@router.post("/api/editor/plan")
|
||||
async def plan(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
body = await _json_object(request)
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
state = body.get("state") or {}
|
||||
mode = str(body.get("mode") or "plan").strip().lower()
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1390,31 +1473,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
@router.post("/api/editor/bridge/command")
|
||||
async def bridge_command(request: Request) -> JSONResponse:
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
session = _scoped(body.get("session"), owner)
|
||||
steps = _normalize_steps(body)
|
||||
wait = bool(body.get("wait"))
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
ids: List[str] = []
|
||||
wait_ids: List[str] = []
|
||||
if len(pending) + len(steps) > max_pending:
|
||||
raise HTTPException(429, "Too many pending editor commands")
|
||||
for step in steps:
|
||||
cid = uuid.uuid4().hex
|
||||
ids.append(cid)
|
||||
if wait:
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
wait_ids.append(cid)
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||
if wait and len(wait_ids) == 1:
|
||||
|
||||
if wait and len(steps) == 1:
|
||||
cid = ids[0]
|
||||
entry = pending.get(cid)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||
if isinstance(res, dict):
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res.setdefault("completed", bool(res.get("ok")))
|
||||
return JSONResponse(res)
|
||||
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(wait_ids[0], None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
||||
return JSONResponse({"queued": ids, "session": session})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||
finally:
|
||||
_expire_command(session, cid, fut)
|
||||
else:
|
||||
for cid in ids:
|
||||
entry = pending.get(cid)
|
||||
if entry is not None:
|
||||
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
|
||||
|
||||
return JSONResponse({
|
||||
"queued": ids,
|
||||
"session": _scope_label(session),
|
||||
"status": "queued",
|
||||
"completed": False,
|
||||
"ok": False,
|
||||
})
|
||||
|
||||
@router.get("/api/editor/bridge/events")
|
||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||
@@ -1439,38 +1540,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/editor/bridge/result")
|
||||
async def bridge_result(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
fut = pending.pop(cid, None)
|
||||
entry = pending.get(cid)
|
||||
if entry is None:
|
||||
return JSONResponse({"ok": True})
|
||||
if entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
|
||||
_, fut = entry
|
||||
if fut is not None and not fut.done():
|
||||
is_ok = bool(body.get("ok"))
|
||||
fut.set_result({
|
||||
"id": cid, "ok": bool(body.get("ok")),
|
||||
"result": body.get("result"), "error": body.get("error"),
|
||||
"id": cid,
|
||||
"ok": is_ok,
|
||||
"status": "completed" if is_ok else "failed",
|
||||
"completed": is_ok,
|
||||
"result": body.get("result"),
|
||||
"error": body.get("error"),
|
||||
"screenshot": body.get("screenshot"),
|
||||
})
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@router.post("/api/editor/bridge/await")
|
||||
async def bridge_await(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
cid = str(body.get("id") or "")
|
||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||
fut = pending.get(cid)
|
||||
if fut is None:
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = fut
|
||||
timeout = _bridge_timeout(body.get("timeout"))
|
||||
entry = pending.get(cid)
|
||||
if entry is None or entry[0] != owner:
|
||||
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||
fut = entry[1]
|
||||
try:
|
||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||
return JSONResponse(result)
|
||||
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||
if pending.get(cid) == (owner, fut):
|
||||
pending.pop(cid, None)
|
||||
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||
res["completed"] = bool(res.get("ok"))
|
||||
return JSONResponse(res)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||
|
||||
_AGENT_SYSTEM_PROMPT = (
|
||||
"You are the photo editor agent. You drive the open image editor and "
|
||||
@@ -1521,17 +1634,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
overwritten.
|
||||
"""
|
||||
owner = _user(request)
|
||||
body = await _json_object(request)
|
||||
from src.settings import get_setting
|
||||
disabled = get_setting("disabled_tools", []) or []
|
||||
if not isinstance(disabled, (list, tuple, set)):
|
||||
disabled = []
|
||||
if "plan_edit" in disabled:
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prompt = str(body.get("prompt") or "").strip()
|
||||
prompt = _prompt(body.get("prompt"))
|
||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||
screenshot = body.get("screenshot")
|
||||
@@ -1542,7 +1652,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
scoped = _scoped(session, owner)
|
||||
trace: List[Dict[str, Any]] = [
|
||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
||||
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||
]
|
||||
if not prompt:
|
||||
return JSONResponse({
|
||||
@@ -1639,8 +1749,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
"results": [],
|
||||
"agent_model": model_used,
|
||||
})
|
||||
# This route dispatches tools directly, outside execute_tool_block.
|
||||
# Apply the same administrator tool switches before any step runs.
|
||||
needed = {"plan_edit"}
|
||||
for step in steps:
|
||||
op = step.get("op")
|
||||
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||
if needed.intersection(disabled):
|
||||
raise HTTPException(403, "Editor tool is disabled")
|
||||
# No image provider: stop before any edit runs and tell the user plainly
|
||||
# (zero canvas changes; nothing to roll back).
|
||||
if _plan_uses_ai_content(steps):
|
||||
from src.auth_helpers import require_privilege
|
||||
require_privilege(request, "can_generate_images")
|
||||
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||
return JSONResponse({
|
||||
@@ -1673,24 +1794,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||
break
|
||||
tool_payload = dict(params)
|
||||
tool_payload.setdefault("session", session)
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload.setdefault("model", model_choice)
|
||||
tool_payload["model"] = model_choice
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
# selection step (the masked path was losing the canvas image).
|
||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||
tool_payload.setdefault("image", screenshot)
|
||||
tool_payload["image"] = screenshot
|
||||
else:
|
||||
tool_payload.pop("image", None)
|
||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||
try:
|
||||
_desc, res = await tool_cls().execute(
|
||||
json.dumps(tool_payload),
|
||||
{"owner": owner, "session_id": scoped},
|
||||
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||
"incognito": body.get("incognito") is True},
|
||||
)
|
||||
except Exception as exc:
|
||||
res = {"error": str(exc), "exit_code": 1}
|
||||
except Exception:
|
||||
logger.exception("Editor image operation failed")
|
||||
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||
res = res if isinstance(res, dict) else {}
|
||||
ok = res.get("exit_code", 0) == 0
|
||||
step_result = {
|
||||
@@ -1708,15 +1835,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
break
|
||||
continue
|
||||
cid = uuid.uuid4().hex
|
||||
pending[cid] = asyncio.get_event_loop().create_future()
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
pending[cid] = (owner, fut)
|
||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||
try:
|
||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
pending.pop(cid, None)
|
||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||
break
|
||||
finally:
|
||||
_expire_command(scoped, cid, fut)
|
||||
results.append(res)
|
||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||
if res.get("ok") is False:
|
||||
@@ -1727,13 +1856,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
if steps and not any(r.get("ok") is False for r in results):
|
||||
try:
|
||||
_info_id = uuid.uuid4().hex
|
||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
||||
_info_fut = asyncio.get_event_loop().create_future()
|
||||
pending[_info_id] = (owner, _info_fut)
|
||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
||||
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||
final_state = _info["result"]
|
||||
except Exception:
|
||||
final_state = {}
|
||||
finally:
|
||||
_expire_command(scoped, _info_id, _info_fut)
|
||||
planned_reply = reply
|
||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||
if special_note and special_note.strip() not in reply:
|
||||
@@ -1770,8 +1902,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
is_admin = _is_admin(request)
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
try:
|
||||
@@ -1795,11 +1926,18 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# router uses.
|
||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||
endpoint_id = getattr(ep, "id", None)
|
||||
provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||
provider = getattr(ep, "name", None)
|
||||
if not provider:
|
||||
provider = (
|
||||
getattr(ep, "base_url", "")
|
||||
if is_admin
|
||||
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||
)
|
||||
provider = provider or "image"
|
||||
for name in _endpoint_enabled_models(ep):
|
||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||
continue
|
||||
key = (str(endpoint_id), name)
|
||||
key = (str(endpoint_id or provider), name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
@@ -1831,21 +1969,20 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
For non-admin users, private URLs and internal probe status are omitted.
|
||||
"""
|
||||
owner = _user(request)
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
is_admin = owner_is_admin_or_single_user(owner)
|
||||
is_admin = _is_admin(request)
|
||||
try:
|
||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||
if not is_admin:
|
||||
sanitized = []
|
||||
for m in models:
|
||||
m_copy = dict(m)
|
||||
m_copy.pop("base_url", None)
|
||||
sanitized.append(m_copy)
|
||||
models = sanitized
|
||||
except Exception:
|
||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||
models = []
|
||||
|
||||
safe_models = []
|
||||
for m in models:
|
||||
entry = dict(m)
|
||||
if not is_admin:
|
||||
entry.pop("base_url", None)
|
||||
safe_models.append(entry)
|
||||
|
||||
probe_info = {}
|
||||
if is_admin:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
@@ -1854,7 +1991,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
probe_info = editor_agent_model.probe_status()
|
||||
|
||||
return JSONResponse({
|
||||
"models": safe_models,
|
||||
"models": models,
|
||||
"probe": probe_info,
|
||||
})
|
||||
|
||||
@@ -1862,21 +1999,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
async def editor_self_check(request: Request) -> JSONResponse:
|
||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||
|
||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
||||
cannot call it directly and the self-check silently "passed". This route
|
||||
A configured vision server may not provide browser CORS headers. This route
|
||||
proxies the two images + intent to the agent VLM and returns
|
||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||
``reason="unavailable"`` on any failure so the client never loops).
|
||||
"""
|
||||
owner = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object(request)
|
||||
result = await editor_agent_model.self_check(
|
||||
body.get("before"),
|
||||
body.get("after"),
|
||||
str(body.get("prompt") or body.get("intent") or ""),
|
||||
_prompt(body.get("prompt") or body.get("intent")),
|
||||
model=str(body.get("model") or "").strip() or None,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user