mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 15:52:20 +02:00
fix(security): restore scoped bearer compatibility
This commit is contained in:
+17
-6
@@ -514,8 +514,18 @@ def _has_auth_keys(headers) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||
def resolve_session_auth(
|
||||
sess,
|
||||
session_id: str,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
):
|
||||
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
||||
if not allow_live_probes:
|
||||
# Bearer chat is cache-only and request-local. Do not resolve provider
|
||||
# credentials or write recovered headers/session state in this mode.
|
||||
return
|
||||
try:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
||||
@@ -594,7 +604,7 @@ def _match_cached_model_id(requested: str, models) -> Optional[str]:
|
||||
|
||||
|
||||
def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
"""Use stored endpoint model IDs before falling back to a live /models probe."""
|
||||
"""Use stored ``cached_models``/pinned IDs before a live /models probe."""
|
||||
endpoint_url = getattr(sess, "endpoint_url", "") or ""
|
||||
requested = getattr(sess, "model", "") or ""
|
||||
if not endpoint_url or not requested:
|
||||
@@ -622,11 +632,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raw_models = getattr(ep, "cached_models", None)
|
||||
if not raw_models:
|
||||
continue
|
||||
try:
|
||||
models = json.loads(raw_models) if isinstance(raw_models, str) else raw_models
|
||||
from routes.model_routes import _effective_endpoint_kind, _picker_models_for_endpoint
|
||||
|
||||
base_url = getattr(ep, "base_url", "") or ""
|
||||
kind = _effective_endpoint_kind(ep, base_url)
|
||||
models, _ = _picker_models_for_endpoint(ep, base_url, kind)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
+64
-15
@@ -55,7 +55,11 @@ from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
from routes.model_routes import _visible_models
|
||||
from routes.model_routes import (
|
||||
_effective_endpoint_kind,
|
||||
_picker_models_for_endpoint,
|
||||
_visible_models,
|
||||
)
|
||||
from routes.chat_helpers import (
|
||||
resolve_session_auth,
|
||||
build_chat_context,
|
||||
@@ -414,8 +418,17 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
return sess in variants or sess.startswith(base + "/")
|
||||
|
||||
|
||||
def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
|
||||
def _clear_orphaned_session_endpoint(
|
||||
sess,
|
||||
owner: str | None = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> bool:
|
||||
"""Clear a session model if its endpoint was deleted from ModelEndpoint."""
|
||||
if not allow_live_probes:
|
||||
# Bearer chat must not turn an orphan check into a session/database
|
||||
# mutation. Interactive callers retain the repair behavior below.
|
||||
return False
|
||||
if not getattr(sess, "endpoint_url", ""):
|
||||
return False
|
||||
db = SessionLocal()
|
||||
@@ -581,13 +594,16 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse cached_models for endpoint %r", getattr(ep, "id", "?"), exc_info=e)
|
||||
cached = []
|
||||
if not cached:
|
||||
visible = []
|
||||
else:
|
||||
try:
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
try:
|
||||
base_url = getattr(ep, "base_url", "") or ""
|
||||
kind = _effective_endpoint_kind(ep, base_url)
|
||||
visible, _ = _picker_models_for_endpoint(ep, base_url, kind)
|
||||
except Exception:
|
||||
visible = _visible_models(
|
||||
cached,
|
||||
getattr(ep, "hidden_models", None),
|
||||
getattr(ep, "pinned_models", None),
|
||||
)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if is_chatgpt_subscription and allow_live_probes:
|
||||
@@ -611,9 +627,15 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
if not cached:
|
||||
return False
|
||||
try:
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
base_url = getattr(ep, "base_url", "") or ""
|
||||
kind = _effective_endpoint_kind(ep, base_url)
|
||||
visible, _ = _picker_models_for_endpoint(ep, base_url, kind)
|
||||
except Exception:
|
||||
visible = cached
|
||||
visible = _visible_models(
|
||||
cached,
|
||||
getattr(ep, "hidden_models", None),
|
||||
getattr(ep, "pinned_models", None),
|
||||
)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if not visible:
|
||||
@@ -661,6 +683,8 @@ def _reconcile_selected_route_from_request(
|
||||
session_id: str,
|
||||
form_data,
|
||||
owner: str | None = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> bool:
|
||||
"""Apply the model route the browser selected before streaming.
|
||||
|
||||
@@ -669,6 +693,11 @@ def _reconcile_selected_route_from_request(
|
||||
stream request includes the route that was selected at click/send time.
|
||||
Trust only registered endpoint ids, or the session's existing endpoint URL.
|
||||
"""
|
||||
if not allow_live_probes:
|
||||
# The bearer path may consume the already-selected session route, but
|
||||
# it must not resolve credentials or persist a browser-supplied route.
|
||||
return False
|
||||
|
||||
selected_model = str(form_data.get("selected_model") or "").strip()
|
||||
selected_endpoint_id = str(form_data.get("selected_endpoint_id") or "").strip()
|
||||
selected_endpoint_url = str(form_data.get("selected_endpoint_url") or "").strip()
|
||||
@@ -795,7 +824,11 @@ def setup_chat_routes(
|
||||
raise HTTPException(404, f"Session '{session}' not found")
|
||||
owner = effective_user(request)
|
||||
request_capability = build_request_capability(request)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
if _clear_orphaned_session_endpoint(
|
||||
sess,
|
||||
owner=owner,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
|
||||
# Empty model + live endpoint = setup race (Issue #587). Repair from
|
||||
@@ -1322,8 +1355,19 @@ def setup_chat_routes(
|
||||
external_untrusted_context_seen = (
|
||||
external_untrusted_context_seen or retired_tool_approval_taint
|
||||
)
|
||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
_reconcile_selected_route_from_request(
|
||||
request,
|
||||
sess,
|
||||
session,
|
||||
form_data,
|
||||
owner=owner,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
if _clear_orphaned_session_endpoint(
|
||||
sess,
|
||||
owner=owner,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
# Issue #587: picker shows a model from the endpoint cache but
|
||||
# s.model never made it onto the DB row (first-send race after
|
||||
@@ -1402,7 +1446,12 @@ def setup_chat_routes(
|
||||
_enforce_chat_privileges(request, sess)
|
||||
|
||||
# Ensure session has auth headers
|
||||
resolve_session_auth(sess, session, owner=effective_user(request))
|
||||
resolve_session_auth(
|
||||
sess,
|
||||
session,
|
||||
owner=effective_user(request),
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Check for research_pending BEFORE mode persist overwrites it
|
||||
# An approval response resumes the sealed agent action. Do not let
|
||||
|
||||
+15
-18
@@ -1,9 +1,9 @@
|
||||
"""Codex integration routes.
|
||||
|
||||
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
|
||||
reuse existing Odysseus helpers. The bridge is an interactive host-control
|
||||
plane and is unavailable to bearer principals; cookie/admin callers retain the
|
||||
documented operation path.
|
||||
reuse existing Odysseus helpers. Owner-scoped data operations support bearer
|
||||
principals with the matching token scope; the Cookbook/plugin host-control
|
||||
plane remains interactive-only.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -90,7 +90,6 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||
require_non_bearer_request(request)
|
||||
if getattr(request.state, "api_token", False) is True:
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if not scopes.intersection(allowed):
|
||||
@@ -102,7 +101,6 @@ def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
|
||||
def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||
"""Return owner only when an API token has every required scope."""
|
||||
require_non_bearer_request(request)
|
||||
if getattr(request.state, "api_token", False) is True:
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
missing = required - scopes
|
||||
@@ -120,6 +118,7 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
||||
privileges because cookbook surfaces expose host topology, task logs, tmux
|
||||
commands, and model-serving controls.
|
||||
"""
|
||||
require_non_bearer_request(request)
|
||||
owner = _scope_owner(request, allowed)
|
||||
if getattr(request.state, "api_token", False) is not True:
|
||||
require_admin(request)
|
||||
@@ -156,7 +155,6 @@ def setup_codex_routes(
|
||||
router = APIRouter(
|
||||
prefix="/api/codex",
|
||||
tags=["codex"],
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
|
||||
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
|
||||
@@ -172,7 +170,6 @@ def setup_codex_routes(
|
||||
|
||||
@router.get("/capabilities")
|
||||
def capabilities(request: Request):
|
||||
require_non_bearer_request(request)
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
has_token = getattr(request.state, "api_token", False) is True
|
||||
def scoped(allowed):
|
||||
@@ -222,7 +219,7 @@ def setup_codex_routes(
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
@router.get("/plugin.zip", dependencies=[Depends(require_non_bearer_request)])
|
||||
def plugin_zip(request: Request):
|
||||
require_non_bearer_request(request)
|
||||
require_authenticated_request(request)
|
||||
@@ -522,7 +519,7 @@ def setup_codex_routes(
|
||||
|
||||
# ── Cookbook surface ──
|
||||
# These handlers retain their legacy scope constants for compatibility
|
||||
# with callers and tests, but the bridge is now an interactive-only
|
||||
# with callers and tests, but the bridge is an interactive-only
|
||||
# host-control plane. Bearer principals are rejected before any task-list,
|
||||
# tmux-output, launch, stop, or model-serving operation.
|
||||
|
||||
@@ -568,14 +565,14 @@ def setup_codex_routes(
|
||||
if k not in ("hf_token", "_secrets")}
|
||||
return clean
|
||||
|
||||
@router.get("/cookbook/tasks")
|
||||
@router.get("/cookbook/tasks", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_tasks(request: Request):
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
return {"tasks": [_redact_task(t) for t in tasks]}
|
||||
|
||||
@router.get("/cookbook/servers")
|
||||
@router.get("/cookbook/servers", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_servers(request: Request):
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
@@ -594,7 +591,7 @@ def setup_codex_routes(
|
||||
})
|
||||
return {"servers": cleaned}
|
||||
|
||||
@router.get("/cookbook/output/{session_id}")
|
||||
@router.get("/cookbook/output/{session_id}", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
# Defensive: session_id must be the tmux-style id we issue
|
||||
@@ -636,7 +633,7 @@ def setup_codex_routes(
|
||||
"task": _redact_task(task),
|
||||
}
|
||||
|
||||
@router.post("/cookbook/serve")
|
||||
@router.post("/cookbook/serve", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
# Wraps /api/model/serve with the SAME validation the UI uses.
|
||||
@@ -675,7 +672,7 @@ def setup_codex_routes(
|
||||
raise HTTPException(503, "model serve endpoint unavailable")
|
||||
return await serve_endpoint(request, req)
|
||||
|
||||
@router.post("/cookbook/stop/{session_id}")
|
||||
@router.post("/cookbook/stop/{session_id}", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_stop(request: Request, session_id: str):
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
import re as _re
|
||||
@@ -692,7 +689,7 @@ def setup_codex_routes(
|
||||
result = await _run_shell(cmd, timeout=10)
|
||||
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"}
|
||||
|
||||
@router.get("/cookbook/cached")
|
||||
@router.get("/cookbook/cached", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_cached(request: Request, host: str | None = None):
|
||||
"""List cached models on a configured server (or local if host is omitted).
|
||||
Mirrors `list_cached_models` from the chat agent so external agents have
|
||||
@@ -754,7 +751,7 @@ def setup_codex_routes(
|
||||
platform=params.get("platform") or None,
|
||||
)
|
||||
|
||||
@router.get("/cookbook/presets")
|
||||
@router.get("/cookbook/presets", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_presets(request: Request):
|
||||
"""List saved serve presets (model + host + port + launch cmd).
|
||||
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
||||
@@ -775,7 +772,7 @@ def setup_codex_routes(
|
||||
})
|
||||
return {"presets": out, "default_host": (state.get("env") or {}).get("defaultServer", "")}
|
||||
|
||||
@router.post("/cookbook/preset/{name}")
|
||||
@router.post("/cookbook/preset/{name}", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_serve_preset(request: Request, name: str):
|
||||
"""Launch a saved preset by name. Reuses the working cmd + host the
|
||||
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
||||
@@ -825,7 +822,7 @@ def setup_codex_routes(
|
||||
raise HTTPException(503, "model serve endpoint unavailable")
|
||||
return await serve_endpoint(request, req)
|
||||
|
||||
@router.post("/cookbook/adopt")
|
||||
@router.post("/cookbook/adopt", dependencies=[Depends(require_non_bearer_request)])
|
||||
async def codex_cookbook_adopt(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
"""Adopt an existing tmux session (one started via raw ssh+tmux) into
|
||||
cookbook tracking. Needed when serve_model rejects a cmd and the
|
||||
|
||||
@@ -792,7 +792,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
if len(session.history) < 6:
|
||||
return {"status": "ok", "message": "Not enough messages to compact"}
|
||||
@@ -822,11 +821,19 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
for m in older
|
||||
)
|
||||
|
||||
# Use utility model if available
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
# Use the utility model only for interactive/live-capable callers.
|
||||
# Bearer compaction remains on the selected session route.
|
||||
if capability.allow_live_probes:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
else:
|
||||
compact_url = session.endpoint_url
|
||||
compact_model = session.model
|
||||
compact_headers = session.headers
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
|
||||
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||
|
||||
@@ -1043,12 +1043,16 @@ def setup_session_routes(
|
||||
raise HTTPException(400, "Nothing old enough to compact")
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
owner = getattr(session, "owner", None) or effective_user(request)
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
||||
if not url or not model:
|
||||
if capability.allow_live_probes:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
||||
if not url or not model:
|
||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||
else:
|
||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||
if not url or not model:
|
||||
raise HTTPException(400, "No model configured for compaction")
|
||||
|
||||
@@ -75,26 +75,53 @@ def _cached_endpoint_model_ids(endpoint) -> list[str]:
|
||||
``auto`` alias, but it must not turn an ordinary chat request into a remote
|
||||
catalog probe. Malformed/legacy cache shapes are treated as empty.
|
||||
"""
|
||||
raw = getattr(endpoint, "cached_models", None)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
value = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data") or value.get("models") or []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
ids = []
|
||||
for item in value:
|
||||
if isinstance(item, str) and item.strip():
|
||||
ids.append(item.strip())
|
||||
elif isinstance(item, dict):
|
||||
model_id = item.get("id") or item.get("name") or item.get("model")
|
||||
if isinstance(model_id, str) and model_id.strip():
|
||||
ids.append(model_id.strip())
|
||||
return ids
|
||||
from routes.model_routes import _effective_endpoint_kind, _picker_models_for_endpoint
|
||||
|
||||
base_url = getattr(endpoint, "base_url", "") or ""
|
||||
kind = _effective_endpoint_kind(endpoint, base_url)
|
||||
models, _ = _picker_models_for_endpoint(endpoint, base_url, kind)
|
||||
return models
|
||||
except Exception:
|
||||
raw = getattr(endpoint, "cached_models", None)
|
||||
pinned_raw = getattr(endpoint, "pinned_models", None)
|
||||
hidden_raw = getattr(endpoint, "hidden_models", None)
|
||||
if not raw and not pinned_raw:
|
||||
return []
|
||||
try:
|
||||
value = json.loads(raw) if isinstance(raw, str) else raw
|
||||
pinned = json.loads(pinned_raw) if isinstance(pinned_raw, str) else pinned_raw
|
||||
hidden = json.loads(hidden_raw) if isinstance(hidden_raw, str) else hidden_raw
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data") or value.get("models") or []
|
||||
if isinstance(pinned, dict):
|
||||
pinned = pinned.get("data") or pinned.get("models") or []
|
||||
if isinstance(hidden, dict):
|
||||
hidden = hidden.get("data") or hidden.get("models") or []
|
||||
if not isinstance(value, list):
|
||||
value = []
|
||||
if not isinstance(pinned, list):
|
||||
pinned = []
|
||||
if not isinstance(hidden, list):
|
||||
hidden = []
|
||||
raw_ids = value + pinned
|
||||
hidden_ids = {str(item).strip() for item in hidden if str(item).strip()}
|
||||
ids = []
|
||||
for item in raw_ids:
|
||||
if isinstance(item, str) and item.strip():
|
||||
model_id = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
model_id = item.get("id") or item.get("name") or item.get("model")
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
continue
|
||||
model_id = model_id.strip()
|
||||
else:
|
||||
continue
|
||||
if model_id not in hidden_ids and model_id not in ids:
|
||||
ids.append(model_id)
|
||||
return ids
|
||||
|
||||
|
||||
def setup_webhook_routes(
|
||||
|
||||
Reference in New Issue
Block a user