mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 13:12:27 +01:00
fix(security): isolate bearer authorization paths
This commit is contained in:
+85
-7
@@ -16,7 +16,12 @@ from src.llm_core import normalize_model_id
|
||||
from src.endpoint_resolver import normalize_base
|
||||
from src.context_compactor import maybe_compact, trim_for_context
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.auth_helpers import effective_user
|
||||
from src.auth_helpers import (
|
||||
RequestCapability,
|
||||
effective_user,
|
||||
request_capability as build_request_capability,
|
||||
)
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.attachment_refs import attachment_ref
|
||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||
@@ -104,6 +109,33 @@ def _append_incognito_message(session_id: str, role: str, content: Any, metadata
|
||||
bundle["updated_at"] = time.time()
|
||||
|
||||
|
||||
def _history_for_request_capability(sess, capability: RequestCapability) -> list[dict[str, Any]]:
|
||||
"""Project persisted history without interactive approval authority for bearers."""
|
||||
history = sess.get_context_messages()
|
||||
if not capability.is_bearer:
|
||||
return history
|
||||
|
||||
# Session.get_context_messages() derives the marker only from the separate
|
||||
# server-owned grant table. A pure bearer chat may still read its owner's
|
||||
# ordinary transcript, but it must not receive even that interactive
|
||||
# approval signal as model context or future tool authority.
|
||||
projected = []
|
||||
for item in history or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
message = dict(item)
|
||||
metadata = message.get("metadata")
|
||||
if isinstance(metadata, dict) and CHAT_SESSION_APPROVAL_CONTEXT_MARKER in metadata:
|
||||
metadata = dict(metadata)
|
||||
metadata.pop(CHAT_SESSION_APPROVAL_CONTEXT_MARKER, None)
|
||||
if metadata:
|
||||
message["metadata"] = metadata
|
||||
else:
|
||||
message.pop("metadata", None)
|
||||
projected.append(message)
|
||||
return projected
|
||||
|
||||
|
||||
# ── Data containers ────────────────────────────────────────────────────── #
|
||||
|
||||
@dataclass
|
||||
@@ -406,7 +438,13 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
|
||||
return manifest
|
||||
|
||||
|
||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||
def add_user_message(
|
||||
sess,
|
||||
chat_handler,
|
||||
preprocessed: PreprocessedMessage,
|
||||
incognito: bool = False,
|
||||
capability: RequestCapability | None = None,
|
||||
):
|
||||
"""Add user message to session history and update session name.
|
||||
Incognito messages must not mutate persistent session history, even in
|
||||
memory, because a later normal turn can persist the same session object."""
|
||||
@@ -414,11 +452,23 @@ def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, inco
|
||||
return
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
||||
if capability is None or capability.allow_auto_naming:
|
||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
||||
|
||||
|
||||
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
||||
def fire_message_event(
|
||||
request,
|
||||
webhook_manager,
|
||||
session_id: str,
|
||||
sess,
|
||||
message: str,
|
||||
compare_mode: bool = False,
|
||||
capability: RequestCapability | None = None,
|
||||
):
|
||||
"""Fire webhook and event_bus events for a new user message."""
|
||||
capability = capability or build_request_capability(request)
|
||||
if not capability.allow_message_events:
|
||||
return
|
||||
if webhook_manager and not compare_mode:
|
||||
webhook_manager.fire_and_forget("chat.message", {
|
||||
"session_id": session_id, "model": sess.model, "message": message[:2000],
|
||||
@@ -626,12 +676,15 @@ async def build_chat_context(
|
||||
defer_context_shaping: bool = False,
|
||||
continuation_context_message: str | None = None,
|
||||
persist_user_message: bool = True,
|
||||
capability: RequestCapability | None = None,
|
||||
) -> ChatContext:
|
||||
"""Build the full context (preface + messages) for an LLM call.
|
||||
|
||||
This is the shared logic between /chat and /chat_stream — preset extraction,
|
||||
message preprocessing, memory/RAG/web injection, compaction, normalization.
|
||||
"""
|
||||
capability = capability or build_request_capability(request)
|
||||
|
||||
# Preset
|
||||
preset = extract_preset(chat_handler, preset_id)
|
||||
|
||||
@@ -653,11 +706,25 @@ async def build_chat_context(
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||
elif persist_user_message:
|
||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||
add_user_message(
|
||||
sess,
|
||||
chat_handler,
|
||||
preprocessed,
|
||||
incognito=False,
|
||||
capability=capability,
|
||||
)
|
||||
|
||||
# Fire events
|
||||
if persist_user_message and not incognito:
|
||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||
fire_message_event(
|
||||
request,
|
||||
webhook_manager,
|
||||
session_id,
|
||||
sess,
|
||||
message,
|
||||
compare_mode,
|
||||
capability=capability,
|
||||
)
|
||||
|
||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
||||
@@ -761,7 +828,11 @@ async def build_chat_context(
|
||||
# Build messages. In Nobody/incognito mode, never read saved session
|
||||
# history: the session id may be a temporary wrapper or, in buggy clients, a
|
||||
# stale normal session id. Only the ephemeral incognito transcript is safe.
|
||||
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
|
||||
messages = preface + (
|
||||
_incognito_messages(session_id)
|
||||
if incognito
|
||||
else _history_for_request_capability(sess, capability)
|
||||
)
|
||||
|
||||
# Current date/time — injected as a standalone *user*-role context message
|
||||
# placed immediately before the latest user turn, NOT folded into the
|
||||
@@ -1173,6 +1244,7 @@ def run_post_response_tasks(
|
||||
owner: str = None,
|
||||
extract_skills: bool = True,
|
||||
allow_background_extraction: bool = True,
|
||||
capability: RequestCapability | None = None,
|
||||
):
|
||||
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.
|
||||
|
||||
@@ -1188,6 +1260,12 @@ def run_post_response_tasks(
|
||||
``_queue_background_extraction`` keeps them from overlapping the *next*
|
||||
turn's request too.
|
||||
"""
|
||||
if capability is not None and not capability.allow_deferred_work:
|
||||
# Pure bearer chat is intentionally synchronous and request-bound.
|
||||
# Do not schedule extraction, teacher/model work, callbacks, or
|
||||
# auto-naming after the authorized request has returned/disconnected.
|
||||
return
|
||||
|
||||
_extraction_jobs: list = []
|
||||
|
||||
# Memory extraction — only every 4th message pair to avoid excess LLM calls
|
||||
|
||||
+36
-4
@@ -44,6 +44,7 @@ from src.auth_helpers import (
|
||||
effective_user,
|
||||
enforce_api_token_chat_controls,
|
||||
get_current_user,
|
||||
request_capability as build_request_capability,
|
||||
require_chat_scope,
|
||||
require_interactive_request,
|
||||
)
|
||||
@@ -74,6 +75,7 @@ from src.tool_policy import (
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
from src.tool_approvals import tool_approval_store
|
||||
from src.tool_approval_provenance import create_chat_session_approval_grant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,7 +98,11 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
|
||||
|
||||
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
"""Persist a consumed approval decision on its existing tool event."""
|
||||
"""Persist display-only resolution state on the existing tool event.
|
||||
|
||||
This metadata is intentionally never consulted for authorization; the
|
||||
separate provenance row is written by the interactive approval path.
|
||||
"""
|
||||
|
||||
approval_key = str(approval_id or "")
|
||||
normalized_decision = str(decision or "").strip().lower()
|
||||
@@ -786,7 +792,8 @@ def setup_chat_routes(
|
||||
# non-streaming path can't be used to bypass).
|
||||
_enforce_chat_privileges(request, sess)
|
||||
|
||||
api_token_request = getattr(request.state, "api_token", False) is True
|
||||
request_capability = build_request_capability(request)
|
||||
api_token_request = request_capability.is_bearer
|
||||
tool_policy = build_effective_tool_policy(last_user_message=message)
|
||||
allow_tool_preprocessing = (
|
||||
not api_token_request
|
||||
@@ -817,6 +824,7 @@ def setup_chat_routes(
|
||||
webhook_manager=webhook_manager,
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
capability=request_capability,
|
||||
)
|
||||
|
||||
# Research injection
|
||||
@@ -925,6 +933,7 @@ def setup_chat_routes(
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=ctx.user,
|
||||
allow_background_extraction=allow_tool_preprocessing,
|
||||
capability=request_capability,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -1003,6 +1012,11 @@ def setup_chat_routes(
|
||||
approval_id=tool_approval_id,
|
||||
allow_bash=allow_bash,
|
||||
)
|
||||
request_capability = build_request_capability(request)
|
||||
# Keep the route decision and the downstream capability derived from
|
||||
# the same verified principal. The explicit control gate above remains
|
||||
# the source of the chat-mode rejection message.
|
||||
api_token_request = request_capability.is_bearer
|
||||
|
||||
# A bearer token is a chat-only integration credential. Reject every
|
||||
# remaining control-plane input before approval lookup, intent
|
||||
@@ -1216,6 +1230,21 @@ def setup_chat_routes(
|
||||
409,
|
||||
"This tool approval could not be consumed.",
|
||||
)
|
||||
if decision == "approve":
|
||||
# The transcript card is display data. Only the exact
|
||||
# interactive, one-use store result may create durable
|
||||
# session-scope provenance for later tool gates.
|
||||
if not create_chat_session_approval_grant(
|
||||
request,
|
||||
approval=exact_tool_approval,
|
||||
approval_id=tool_approval_id,
|
||||
session_id=session,
|
||||
owner=owner,
|
||||
):
|
||||
logger.warning(
|
||||
"Tool approval %s ran without a durable chat-session grant",
|
||||
tool_approval_id,
|
||||
)
|
||||
if not _mark_tool_approval_resolved(
|
||||
sess,
|
||||
tool_approval_id,
|
||||
@@ -1418,6 +1447,7 @@ def setup_chat_routes(
|
||||
else None
|
||||
),
|
||||
persist_user_message=not tool_approval_continuation,
|
||||
capability=request_capability,
|
||||
)
|
||||
|
||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||
@@ -1652,7 +1682,7 @@ def setup_chat_routes(
|
||||
# Persist session mode after policy/privilege gates so blocked research
|
||||
# turns remain ordinary chat/agent streams and saved messages.
|
||||
_effective_mode = 'research' if effective_do_research else (chat_mode or 'chat')
|
||||
if _effective_mode in ('agent', 'research', 'chat'):
|
||||
if _effective_mode in ('agent', 'research', 'chat') and not request_capability.is_bearer:
|
||||
set_session_mode(session, _effective_mode)
|
||||
|
||||
async def stream_with_save() -> AsyncGenerator[str, None]:
|
||||
@@ -2299,6 +2329,7 @@ def setup_chat_routes(
|
||||
allow_tool_preprocessing
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
capability=request_capability,
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
@@ -2573,6 +2604,7 @@ def setup_chat_routes(
|
||||
allow_tool_preprocessing
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
capability=request_capability,
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
@@ -2647,7 +2679,7 @@ def setup_chat_routes(
|
||||
# buffered output + live); dropping the SSE only removes a subscriber —
|
||||
# the run keeps going and saves the assistant message on completion
|
||||
# regardless. Reconnect via /api/chat/resume.
|
||||
if compare_mode:
|
||||
if compare_mode or not request_capability.allow_detached_execution:
|
||||
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
|
||||
|
||||
_detached_run = agent_runs.start(session, _safe_stream())
|
||||
|
||||
+27
-17
@@ -1,8 +1,9 @@
|
||||
"""Codex integration routes.
|
||||
|
||||
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
|
||||
reuse existing Odysseus helpers and enforce API-token scopes before touching
|
||||
user data.
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -12,13 +13,14 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import (
|
||||
require_api_token_owner,
|
||||
require_authenticated_request,
|
||||
require_non_bearer_request,
|
||||
require_user,
|
||||
)
|
||||
from src.tool_implementations import do_manage_notes
|
||||
@@ -88,6 +90,7 @@ 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):
|
||||
@@ -99,6 +102,7 @@ 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
|
||||
@@ -111,9 +115,9 @@ def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
||||
"""Authorize a Codex cookbook route.
|
||||
|
||||
For API-token callers, enforce the given scope set.
|
||||
For cookie-session callers, additionally require admin privileges
|
||||
because cookbook surfaces expose host topology, task logs, tmux
|
||||
Bearer callers are rejected by the host-control boundary regardless of
|
||||
legacy scope labels. Cookie-session callers additionally require admin
|
||||
privileges because cookbook surfaces expose host topology, task logs, tmux
|
||||
commands, and model-serving controls.
|
||||
"""
|
||||
owner = _scope_owner(request, allowed)
|
||||
@@ -149,7 +153,11 @@ def setup_codex_routes(
|
||||
calendar_router: APIRouter | None = None,
|
||||
document_router: APIRouter | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/codex", tags=["codex"])
|
||||
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}")
|
||||
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
|
||||
@@ -164,6 +172,7 @@ 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):
|
||||
@@ -215,6 +224,7 @@ def setup_codex_routes(
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_non_bearer_request(request)
|
||||
require_authenticated_request(request)
|
||||
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
|
||||
if not root.exists():
|
||||
@@ -511,15 +521,10 @@ def setup_codex_routes(
|
||||
return await _as_owner(request, owner, documents_create_endpoint, request, req)
|
||||
|
||||
# ── Cookbook surface ──
|
||||
# Lets the agent run the same launch / monitor / kill loop the user
|
||||
# would do by hand in the Cookbook UI: read the current task list +
|
||||
# tmux output, launch a serve task, stop one. Two scopes:
|
||||
# cookbook:read — list tasks + tail output + list servers
|
||||
# cookbook:launch — also start/stop serves (host shell exec)
|
||||
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
|
||||
# commands on the user's hosts. The existing _validate_serve_cmd
|
||||
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
|
||||
# keeps the agent inside the same sandbox the UI uses.
|
||||
# These handlers retain their legacy scope constants for compatibility
|
||||
# with callers and tests, but the bridge is now an interactive-only
|
||||
# host-control plane. Bearer principals are rejected before any task-list,
|
||||
# tmux-output, launch, stop, or model-serving operation.
|
||||
|
||||
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
|
||||
"""Run a shell command, return {exit_code, stdout, stderr}."""
|
||||
@@ -884,10 +889,15 @@ def setup_claude_routes() -> APIRouter:
|
||||
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
|
||||
so the user-facing setup commands stay in the Claude namespace.
|
||||
"""
|
||||
router = APIRouter(prefix="/api/claude", tags=["claude"])
|
||||
router = APIRouter(
|
||||
prefix="/api/claude",
|
||||
tags=["claude"],
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_non_bearer_request(request)
|
||||
require_authenticated_request(request)
|
||||
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
|
||||
# README.md or other bundle metadata into the user's claude config dir.
|
||||
|
||||
@@ -27,6 +27,24 @@ _HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
|
||||
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
|
||||
|
||||
|
||||
def _metadata_dict(value: Any) -> dict:
|
||||
"""Return only mapping-shaped message metadata.
|
||||
|
||||
Legacy rows and client payloads can contain JSON lists/scalars. They are
|
||||
display noise, not trusted fields, and must not reach ``dict.update`` or
|
||||
approval projection code.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _history_display_content(content: Any) -> Any:
|
||||
"""Return a lightweight browser-display copy of stored message content.
|
||||
|
||||
@@ -126,12 +144,9 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
|
||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
meta = _metadata_dict(m.meta_data)
|
||||
if meta:
|
||||
meta = dict(meta)
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
if meta:
|
||||
@@ -199,21 +214,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
for msg in session.history:
|
||||
if isinstance(msg, ChatMessage):
|
||||
# Skip hidden messages (e.g. compaction summaries for AI context)
|
||||
if msg.metadata and msg.metadata.get("hidden"):
|
||||
msg_meta = _metadata_dict(msg.metadata)
|
||||
if msg_meta.get("hidden"):
|
||||
continue
|
||||
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
||||
if msg.metadata:
|
||||
entry["metadata"] = msg.metadata
|
||||
if msg_meta:
|
||||
entry["metadata"] = msg_meta
|
||||
history_dict.append(entry)
|
||||
elif isinstance(msg, dict):
|
||||
if msg.get("metadata", {}).get("hidden"):
|
||||
msg_meta = _metadata_dict(msg.get("metadata"))
|
||||
if msg_meta.get("hidden"):
|
||||
continue
|
||||
entry = {
|
||||
"role": msg.get("role", ""),
|
||||
"content": _history_display_content(msg.get("content", "")),
|
||||
}
|
||||
if msg.get("metadata"):
|
||||
entry["metadata"] = msg["metadata"]
|
||||
if msg_meta:
|
||||
entry["metadata"] = msg_meta
|
||||
history_dict.append(entry)
|
||||
|
||||
# Fallback: load from DB if in-memory renders empty. Display only —
|
||||
@@ -370,9 +387,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
|
||||
db_msg.content = content
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta = _metadata_dict(db_msg.meta_data)
|
||||
meta = dict(meta)
|
||||
meta['edited'] = True
|
||||
db_msg.meta_data = json.dumps(meta)
|
||||
|
||||
@@ -412,13 +428,13 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
if not isinstance(msg.metadata, dict):
|
||||
msg.metadata = {}
|
||||
msg.metadata['stopped'] = True
|
||||
if not msg.metadata.get('model'):
|
||||
msg.metadata['model'] = session.model
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
if not isinstance(msg.get('metadata'), dict):
|
||||
msg['metadata'] = {}
|
||||
msg['metadata']['stopped'] = True
|
||||
if not msg['metadata'].get('model'):
|
||||
@@ -436,11 +452,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
if db_messages:
|
||||
meta = {}
|
||||
if db_messages.meta_data:
|
||||
try:
|
||||
meta = _json.loads(db_messages.meta_data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
meta = _metadata_dict(db_messages.meta_data)
|
||||
meta = dict(meta)
|
||||
meta['stopped'] = True
|
||||
if not meta.get('model'):
|
||||
meta['model'] = session.model
|
||||
@@ -471,11 +484,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
if not isinstance(msg.metadata, dict):
|
||||
msg.metadata = {}
|
||||
msg.metadata.update(meta_update)
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
if not isinstance(msg.get('metadata'), dict):
|
||||
msg['metadata'] = {}
|
||||
msg['metadata'].update(meta_update)
|
||||
break
|
||||
@@ -491,10 +504,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.first()
|
||||
)
|
||||
if db_msg:
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = _json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta = dict(_metadata_dict(db_msg.meta_data))
|
||||
meta.update(meta_update)
|
||||
db_msg.meta_data = _json.dumps(meta)
|
||||
db.commit()
|
||||
@@ -536,8 +546,12 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
merged_content = content1 + separator + content2
|
||||
|
||||
# Merge metadata
|
||||
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
|
||||
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
|
||||
meta1 = dict(_metadata_dict(
|
||||
msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')
|
||||
))
|
||||
meta2 = dict(_metadata_dict(
|
||||
msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')
|
||||
))
|
||||
merged_meta = {**meta1, **meta2}
|
||||
merged_meta.pop('stopped', None) # no longer stopped after continue
|
||||
|
||||
@@ -693,11 +707,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
pct = max(0.0, min(100.0, pct))
|
||||
visible_messages = sum(
|
||||
1 for m in session.history
|
||||
if not (getattr(m, "metadata", None) or {}).get("hidden")
|
||||
if not _metadata_dict(getattr(m, "metadata", None)).get("hidden")
|
||||
)
|
||||
compacted_messages = sum(
|
||||
1 for m in session.history
|
||||
if (getattr(m, "metadata", None) or {}).get("compacted")
|
||||
if _metadata_dict(getattr(m, "metadata", None)).get("compacted")
|
||||
)
|
||||
can_compact = used > 0
|
||||
return {
|
||||
|
||||
+19
-6
@@ -5,10 +5,11 @@ import shlex
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from core.platform_compat import run_ssh_command
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from src.auth_helpers import require_non_bearer_request
|
||||
|
||||
|
||||
# Backends the manual hardware simulator accepts. Must stay a subset of what
|
||||
@@ -180,24 +181,32 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
|
||||
|
||||
|
||||
def setup_hwfit_routes():
|
||||
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
|
||||
router = APIRouter(
|
||||
prefix="/api/hwfit",
|
||||
tags=["hwfit"],
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
|
||||
@router.get("/system")
|
||||
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
|
||||
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, request: Request = None):
|
||||
"""Detect and return current system hardware info. Pass host=user@server for remote.
|
||||
fresh=true bypasses the per-host cache (the Rescan button)."""
|
||||
if request is not None:
|
||||
require_non_bearer_request(request)
|
||||
from services.hwfit.hardware import detect_system
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
|
||||
@router.get("/models")
|
||||
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
|
||||
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False, request: Request = None):
|
||||
"""Rank LLM models against detected hardware and return scored results.
|
||||
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
|
||||
active group). gpu_group: index into system.gpu_groups (the homogeneous
|
||||
pools) to target — empty/auto = the largest pool. vLLM can only
|
||||
tensor-parallel across identical GPUs, so we never mix pools.
|
||||
fresh=true bypasses the hardware-detection cache."""
|
||||
if request is not None:
|
||||
require_non_bearer_request(request)
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
|
||||
@@ -316,7 +325,7 @@ def setup_hwfit_routes():
|
||||
return payload
|
||||
|
||||
@router.get("/profiles")
|
||||
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""):
|
||||
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = "", request: Request = None):
|
||||
"""Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
|
||||
against the detected hardware on `host` (or local). Returns concrete
|
||||
flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply.
|
||||
@@ -325,6 +334,8 @@ def setup_hwfit_routes():
|
||||
catalog (e.g. an ad-hoc HF repo), pass enough hints via a minimal synthetic
|
||||
entry isn't possible here, so we return [] and the UI keeps manual flags.
|
||||
"""
|
||||
if request is not None:
|
||||
require_non_bearer_request(request)
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.models import get_models
|
||||
from services.hwfit.profiles import compute_serve_profiles
|
||||
@@ -410,8 +421,10 @@ def setup_hwfit_routes():
|
||||
}
|
||||
|
||||
@router.get("/image-models")
|
||||
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False):
|
||||
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, request: Request = None):
|
||||
"""Rank image generation models against detected hardware."""
|
||||
if request is not None:
|
||||
require_non_bearer_request(request)
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.image_models import rank_image_models
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# routes/memory_routes.py
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, UploadFile, File
|
||||
from typing import Dict, Any, Optional, List
|
||||
import json
|
||||
import os
|
||||
@@ -53,9 +53,19 @@ def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
|
||||
|
||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||
"""Set up memory-related routes."""
|
||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||
router = APIRouter(
|
||||
prefix="/api/memory",
|
||||
tags=["memory"],
|
||||
dependencies=[Depends(require_user)],
|
||||
)
|
||||
|
||||
def _owner(request: Request) -> Optional[str]:
|
||||
# Router dependencies do not run when a handler is called directly
|
||||
# (including through an integration router), so keep the same bearer
|
||||
# rejection at the owner-resolution seam. ``None`` is retained only
|
||||
# for legacy unit callers; real ASGI requests always carry Request.
|
||||
if request is not None:
|
||||
require_user(request)
|
||||
return get_current_user(request)
|
||||
|
||||
def _assert_session_owner(session_obj, user):
|
||||
|
||||
+41
-10
@@ -29,7 +29,12 @@ from src.endpoint_resolver import (
|
||||
build_models_url,
|
||||
build_headers,
|
||||
)
|
||||
from src.auth_helpers import _auth_disabled, owner_filter, require_chat_scope
|
||||
from src.auth_helpers import (
|
||||
_auth_disabled,
|
||||
is_bearer_principal,
|
||||
owner_filter,
|
||||
require_chat_scope,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1536,7 +1541,12 @@ def setup_model_routes(model_discovery):
|
||||
_refresh_inflight["v"] = False
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _fetch_models(owner: str = "", is_admin: bool = False):
|
||||
def _fetch_models(
|
||||
owner: str = "",
|
||||
is_admin: bool = False,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
):
|
||||
"""Return model list from cached data (instant). Background refresh keeps caches fresh.
|
||||
|
||||
SECURITY: filters endpoints by `owner` — without this the picker
|
||||
@@ -1551,7 +1561,7 @@ def setup_model_routes(model_discovery):
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if _disable_stale_cookbook_local_endpoints(db):
|
||||
if not read_only and _disable_stale_cookbook_local_endpoints(db):
|
||||
_invalidate_models_cache()
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner and not is_admin:
|
||||
@@ -1634,6 +1644,17 @@ def setup_model_routes(model_discovery):
|
||||
except Exception as e:
|
||||
logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Internal error")
|
||||
bearer = is_bearer_principal(request)
|
||||
if bearer and (refresh or background):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"API tokens may only read the owner-scoped cached model list",
|
||||
)
|
||||
if bearer:
|
||||
# The bearer-compatible path is deliberately read-only: no global
|
||||
# admin view, stale-row cleanup, process cache writes, background
|
||||
# probes, stored endpoint credentials, or refresh state changes.
|
||||
return _fetch_models(owner=owner, is_admin=False, read_only=True)
|
||||
# Admins see every endpoint (they manage the global pool); regular
|
||||
# users get the owner-scoped view.
|
||||
_is_admin = False
|
||||
@@ -2413,11 +2434,11 @@ def setup_model_routes(model_discovery):
|
||||
# no per-user default yet, we resolve via the owner-scoped endpoint
|
||||
# lookup below (last-resort: first enabled endpoint THIS user owns).
|
||||
# Unauthenticated single-user mode keeps the old behavior.
|
||||
from src.auth_helpers import get_current_user as _gcu
|
||||
try:
|
||||
_user = _gcu(request) or ""
|
||||
except Exception:
|
||||
_user = ""
|
||||
# Resolve through the same owner/scope gate as the model picker. In an
|
||||
# auth-disabled process there is no middleware to stamp token state, so
|
||||
# raw bearer detection must still prevent a token from resolving
|
||||
# global/admin defaults.
|
||||
_user = require_chat_scope(request) or ""
|
||||
# Admins resolve via the global defaults (they own them, and the
|
||||
# scoped resolution was making the picker disappear for them).
|
||||
# Regular users get per-user prefs with NO global fallback for the
|
||||
@@ -2427,7 +2448,12 @@ def setup_model_routes(model_discovery):
|
||||
_is_admin = False
|
||||
try:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if _user and auth_mgr is not None and getattr(auth_mgr, "is_admin", None):
|
||||
if (
|
||||
_user
|
||||
and not is_bearer_principal(request)
|
||||
and auth_mgr is not None
|
||||
and getattr(auth_mgr, "is_admin", None)
|
||||
):
|
||||
_is_admin = bool(auth_mgr.is_admin(_user))
|
||||
except Exception:
|
||||
_is_admin = False
|
||||
@@ -2686,8 +2712,13 @@ def setup_model_routes(model_discovery):
|
||||
# ── Tool management ──
|
||||
|
||||
@router.get("/tools")
|
||||
def list_tools():
|
||||
def list_tools(request: Request):
|
||||
"""List all available tools with their enabled/disabled status."""
|
||||
# Tool inventory is an interactive/agent capability description, not
|
||||
# part of the narrow bearer chat contract. Cookie/local callers retain
|
||||
# the historical response.
|
||||
from src.auth_helpers import require_non_bearer_request
|
||||
require_non_bearer_request(request)
|
||||
from src.agent_tools import TOOL_TAGS
|
||||
settings = _load_settings()
|
||||
disabled = set(settings.get("disabled_tools", []))
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from src.auth_helpers import is_bearer_principal
|
||||
from src.host_docker_access import (
|
||||
HOST_DOCKER_ACCESS_HINT,
|
||||
host_docker_access_enabled as _host_docker_access_enabled,
|
||||
@@ -53,6 +54,11 @@ from core.platform_compat import (
|
||||
def _require_admin(request: Request):
|
||||
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
||||
regular users; that's RCE-after-signup."""
|
||||
# This route predates the shared middleware helper and is also called
|
||||
# directly by a few integration paths. Reject the credential class before
|
||||
# trusting a caller-supplied current_user that might look administrative.
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens cannot use admin host-control surfaces")
|
||||
auth_manager = getattr(request.app.state, "auth_manager", None)
|
||||
if not auth_manager:
|
||||
# No auth at all — only safe in fully-trusted localhost dev mode
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, require_non_bearer_request
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
|
||||
@@ -24,6 +24,7 @@ def setup_workspace_routes():
|
||||
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
||||
be able to map the host's directory tree either.
|
||||
"""
|
||||
require_non_bearer_request(request)
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
||||
@@ -75,6 +76,7 @@ def setup_workspace_routes():
|
||||
instead of being stored client-side and silently dropped at chat time.
|
||||
Admin-gated like /browse: it confirms path existence on the host.
|
||||
"""
|
||||
require_non_bearer_request(request)
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")
|
||||
|
||||
Reference in New Issue
Block a user