fix(security): restore scoped bearer compatibility

This commit is contained in:
RaresKeY
2026-08-30 15:35:17 +00:00
parent ddeeb10f59
commit 0b32ecea50
11 changed files with 468 additions and 91 deletions
+17 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13 -6
View File
@@ -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 ""))
+7 -3
View File
@@ -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")
+46 -19
View File
@@ -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(