mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
fix(security): close API token agent authorization gaps
This commit is contained in:
+28
-5
@@ -151,11 +151,34 @@ class Session:
|
|||||||
the model. Display/history-load paths use the raw ``history`` and are
|
the model. Display/history-load paths use the raw ``history`` and are
|
||||||
unaffected.
|
unaffected.
|
||||||
"""
|
"""
|
||||||
messages = [
|
messages = []
|
||||||
msg.to_dict()
|
for msg in self.history:
|
||||||
for msg in self.history
|
raw_metadata = getattr(msg, "metadata", None)
|
||||||
if (msg.metadata or {}).get("source") != "slash"
|
if isinstance(raw_metadata, dict) and raw_metadata.get("source") == "slash":
|
||||||
]
|
continue
|
||||||
|
projected = msg.to_dict()
|
||||||
|
metadata = projected.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
# Old or malformed durable rows must not make context
|
||||||
|
# projection fail, and non-mapping metadata has no trusted
|
||||||
|
# fields that belong in the model context.
|
||||||
|
projected.pop("metadata", None)
|
||||||
|
messages.append(projected)
|
||||||
|
continue
|
||||||
|
if isinstance(metadata, dict) and CHAT_SESSION_APPROVAL_CONTEXT_MARKER in metadata:
|
||||||
|
# The marker is derived below from a verified persisted
|
||||||
|
# approval event. Never pass a raw durable/client marker
|
||||||
|
# through to the model context.
|
||||||
|
metadata = {
|
||||||
|
key: value
|
||||||
|
for key, value in metadata.items()
|
||||||
|
if key != CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
|
}
|
||||||
|
if metadata:
|
||||||
|
projected["metadata"] = metadata
|
||||||
|
else:
|
||||||
|
projected.pop("metadata", None)
|
||||||
|
messages.append(projected)
|
||||||
if not _history_grants_chat_session_approval(self.history, self.id):
|
if not _history_grants_chat_session_approval(self.history, self.id):
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import require_interactive_request
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
@@ -78,10 +78,14 @@ def _task_to_checkin_dict(t: ScheduledTask) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def setup_assistant_routes(task_scheduler) -> APIRouter:
|
def setup_assistant_routes(task_scheduler) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
|
router = APIRouter(
|
||||||
|
prefix="/api/assistant",
|
||||||
|
tags=["assistant"],
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
|
|
||||||
def _owner(request: Request) -> str:
|
def _owner(request: Request) -> str:
|
||||||
owner = get_current_user(request)
|
owner = require_interactive_request(request)
|
||||||
if not owner:
|
if not owner:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
return owner
|
return owner
|
||||||
|
|||||||
@@ -731,6 +731,7 @@ async def build_chat_context(
|
|||||||
agent_mode=agent_mode,
|
agent_mode=agent_mode,
|
||||||
incognito=incognito,
|
incognito=incognito,
|
||||||
use_skills=skills_enabled,
|
use_skills=skills_enabled,
|
||||||
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
)
|
)
|
||||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||||
_preface_kwargs["use_rag"] = use_rag_val
|
_preface_kwargs["use_rag"] = use_rag_val
|
||||||
|
|||||||
+84
-29
@@ -9,7 +9,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
from typing import Dict, Any, AsyncGenerator, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
from fastapi import APIRouter, Depends, Request, HTTPException, Form, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ from src.auth_helpers import (
|
|||||||
enforce_api_token_chat_controls,
|
enforce_api_token_chat_controls,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_chat_scope,
|
require_chat_scope,
|
||||||
|
require_interactive_request,
|
||||||
)
|
)
|
||||||
from routes.session_routes import _verify_session_owner
|
from routes.session_routes import _verify_session_owner
|
||||||
from routes.document_helpers import _owner_session_filter
|
from routes.document_helpers import _owner_session_filter
|
||||||
@@ -736,7 +737,7 @@ def setup_chat_routes(
|
|||||||
webhook_manager=None,
|
webhook_manager=None,
|
||||||
skills_manager=None,
|
skills_manager=None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(tags=["chat"])
|
router = APIRouter(tags=["chat"], dependencies=[Depends(require_chat_scope)])
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# POST /api/chat (non-streaming)
|
# POST /api/chat (non-streaming)
|
||||||
@@ -754,6 +755,9 @@ def setup_chat_routes(
|
|||||||
time_filter = chat_request.time_filter
|
time_filter = chat_request.time_filter
|
||||||
preset_id = chat_request.preset_id
|
preset_id = chat_request.preset_id
|
||||||
|
|
||||||
|
if getattr(request.state, "api_token", False) is True and use_research:
|
||||||
|
raise HTTPException(403, "API tokens cannot use research or agent execution")
|
||||||
|
|
||||||
# Verify the caller owns this session before loading it.
|
# Verify the caller owns this session before loading it.
|
||||||
# Without this, any authenticated user can post into another user's chat.
|
# Without this, any authenticated user can post into another user's chat.
|
||||||
_verify_session_owner(request, session)
|
_verify_session_owner(request, session)
|
||||||
@@ -782,15 +786,16 @@ def setup_chat_routes(
|
|||||||
# non-streaming path can't be used to bypass).
|
# non-streaming path can't be used to bypass).
|
||||||
_enforce_chat_privileges(request, sess)
|
_enforce_chat_privileges(request, sess)
|
||||||
|
|
||||||
|
api_token_request = getattr(request.state, "api_token", False) is True
|
||||||
tool_policy = build_effective_tool_policy(last_user_message=message)
|
tool_policy = build_effective_tool_policy(last_user_message=message)
|
||||||
allow_tool_preprocessing = (
|
allow_tool_preprocessing = (
|
||||||
not getattr(request.state, "api_token", False)
|
not api_token_request
|
||||||
and not tool_policy.block_all_tool_calls
|
and not tool_policy.block_all_tool_calls
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inline memory command
|
# Inline memory command
|
||||||
memory_response = None
|
memory_response = None
|
||||||
if not tool_policy.blocks("manage_memory"):
|
if allow_tool_preprocessing and not tool_policy.blocks("manage_memory"):
|
||||||
memory_response = await chat_handler.handle_memory_command(sess, message)
|
memory_response = await chat_handler.handle_memory_command(sess, message)
|
||||||
if memory_response:
|
if memory_response:
|
||||||
return {"response": memory_response}
|
return {"response": memory_response}
|
||||||
@@ -819,7 +824,7 @@ def setup_chat_routes(
|
|||||||
tool_policy.blocks("trigger_research")
|
tool_policy.blocks("trigger_research")
|
||||||
or tool_policy.blocks("manage_research")
|
or tool_policy.blocks("manage_research")
|
||||||
)
|
)
|
||||||
if use_research and not research_blocked_by_policy:
|
if use_research and not api_token_request and not research_blocked_by_policy:
|
||||||
try:
|
try:
|
||||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||||
research_ctx = await research_handler.call_research_service(
|
research_ctx = await research_handler.call_research_service(
|
||||||
@@ -919,7 +924,7 @@ def setup_chat_routes(
|
|||||||
ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
owner=ctx.user,
|
owner=ctx.user,
|
||||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
allow_background_extraction=allow_tool_preprocessing,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -958,6 +963,8 @@ def setup_chat_routes(
|
|||||||
attachments = form_data.get("attachments")
|
attachments = form_data.get("attachments")
|
||||||
use_web = form_data.get("use_web")
|
use_web = form_data.get("use_web")
|
||||||
use_research = form_data.get("use_research")
|
use_research = form_data.get("use_research")
|
||||||
|
if use_research is None:
|
||||||
|
use_research = (body or {}).get("use_research")
|
||||||
time_filter = form_data.get("time_filter")
|
time_filter = form_data.get("time_filter")
|
||||||
preset_id = form_data.get("preset_id")
|
preset_id = form_data.get("preset_id")
|
||||||
selected_endpoint_id = str(
|
selected_endpoint_id = str(
|
||||||
@@ -996,10 +1003,35 @@ def setup_chat_routes(
|
|||||||
approval_id=tool_approval_id,
|
approval_id=tool_approval_id,
|
||||||
allow_bash=allow_bash,
|
allow_bash=allow_bash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A bearer token is a chat-only integration credential. Reject every
|
||||||
|
# remaining control-plane input before approval lookup, intent
|
||||||
|
# detection, context shaping, or active-email/workspace resolution can
|
||||||
|
# turn the request into an interactive agent turn.
|
||||||
|
approved_plan = ""
|
||||||
|
if not plan_mode:
|
||||||
|
approved_plan = str(
|
||||||
|
form_data.get("approved_plan")
|
||||||
|
or (body or {}).get("approved_plan")
|
||||||
|
or ""
|
||||||
|
).strip()[:8192]
|
||||||
|
if api_token_request and (
|
||||||
|
str(use_research).lower() == "true"
|
||||||
|
or bool(tool_approval_decision)
|
||||||
|
or bool(approved_plan)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"API tokens cannot use research, agent, plan, or tool approvals",
|
||||||
|
)
|
||||||
|
|
||||||
# Workspace: confine the agent's file/shell tools to this folder.
|
# Workspace: confine the agent's file/shell tools to this folder.
|
||||||
workspace, workspace_rejected = _resolve_request_workspace(
|
if api_token_request:
|
||||||
request, form_data.get("workspace")
|
workspace, workspace_rejected = "", ""
|
||||||
)
|
else:
|
||||||
|
workspace, workspace_rejected = _resolve_request_workspace(
|
||||||
|
request, form_data.get("workspace")
|
||||||
|
)
|
||||||
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
||||||
if plan_mode:
|
if plan_mode:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
@@ -1008,9 +1040,6 @@ def setup_chat_routes(
|
|||||||
# weak model survives history truncation — the agent can always re-read
|
# weak model survives history truncation — the agent can always re-read
|
||||||
# the plan. Ignored while still proposing (plan_mode on). Capped so a
|
# the plan. Ignored while still proposing (plan_mode on). Capped so a
|
||||||
# huge plan can't blow the prompt.
|
# huge plan can't blow the prompt.
|
||||||
approved_plan = ""
|
|
||||||
if not plan_mode:
|
|
||||||
approved_plan = (form_data.get("approved_plan") or "").strip()[:8192]
|
|
||||||
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
|
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
|
||||||
# below). Skill extraction should only learn from real agent sessions,
|
# below). Skill extraction should only learn from real agent sessions,
|
||||||
# not chats we quietly promoted for a notes/calendar intent.
|
# not chats we quietly promoted for a notes/calendar intent.
|
||||||
@@ -1045,7 +1074,7 @@ def setup_chat_routes(
|
|||||||
auto_escalated = False
|
auto_escalated = False
|
||||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||||
_workspace_agent_intent = False
|
_workspace_agent_intent = False
|
||||||
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
|
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
|
||||||
@@ -1056,24 +1085,28 @@ def setup_chat_routes(
|
|||||||
_tool_intent.category,
|
_tool_intent.category,
|
||||||
_tool_intent.reason,
|
_tool_intent.reason,
|
||||||
)
|
)
|
||||||
elif chat_mode == "chat" and _search_enabled:
|
elif not api_token_request and chat_mode == "chat" and _search_enabled:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
logger.info("chat→agent auto-escalation: search enabled")
|
logger.info("chat→agent auto-escalation: search enabled")
|
||||||
elif chat_mode == "chat" and _explicit_web_intent:
|
elif not api_token_request and chat_mode == "chat" and _explicit_web_intent:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
logger.info("chat→agent auto-escalation: explicit web intent")
|
logger.info("chat→agent auto-escalation: explicit web intent")
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = "" if api_token_request else str(form_data.get("active_doc_id") or "").strip()
|
||||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||||
|
|
||||||
# Active email reader — when the user has an email open in the UI, the
|
# Active email reader — when the user has an email open in the UI, the
|
||||||
# frontend passes its uid/folder/account so "reply", "summarize this",
|
# frontend passes its uid/folder/account so "reply", "summarize this",
|
||||||
# etc. resolve to the real email instead of the agent inventing a
|
# etc. resolve to the real email instead of the agent inventing a
|
||||||
# fake markdown draft.
|
# fake markdown draft.
|
||||||
active_email_uid = form_data.get("active_email_uid", "").strip()
|
active_email_uid = "" if api_token_request else str(form_data.get("active_email_uid") or "").strip()
|
||||||
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
|
active_email_folder = (
|
||||||
active_email_account = form_data.get("active_email_account", "").strip()
|
"INBOX"
|
||||||
|
if api_token_request
|
||||||
|
else str(form_data.get("active_email_folder") or "INBOX").strip() or "INBOX"
|
||||||
|
)
|
||||||
|
active_email_account = "" if api_token_request else str(form_data.get("active_email_account") or "").strip()
|
||||||
active_email_ctx: Optional[Dict[str, str]] = None
|
active_email_ctx: Optional[Dict[str, str]] = None
|
||||||
# Always reset between requests so a stale active-email pointer from
|
# Always reset between requests so a stale active-email pointer from
|
||||||
# a previous turn (different reader closed, different account, etc.)
|
# a previous turn (different reader closed, different account, etc.)
|
||||||
@@ -1246,7 +1279,8 @@ def setup_chat_routes(
|
|||||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||||
if (
|
if (
|
||||||
chat_mode == "chat"
|
not api_token_request
|
||||||
|
and chat_mode == "chat"
|
||||||
and isinstance(message, str)
|
and isinstance(message, str)
|
||||||
and (not _tool_intent or not _tool_intent.needs_tools)
|
and (not _tool_intent or not _tool_intent.needs_tools)
|
||||||
and _is_contextual_web_followup(message, sess)
|
and _is_contextual_web_followup(message, sess)
|
||||||
@@ -1260,14 +1294,14 @@ def setup_chat_routes(
|
|||||||
_tool_intent.category,
|
_tool_intent.category,
|
||||||
_tool_intent.reason,
|
_tool_intent.reason,
|
||||||
)
|
)
|
||||||
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
if not api_token_request and isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
||||||
_explicit_browser_intent = True
|
_explicit_browser_intent = True
|
||||||
if chat_mode == "chat":
|
if chat_mode == "chat":
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
_workspace_agent_intent = False
|
_workspace_agent_intent = False
|
||||||
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
|
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
|
||||||
if not workspace and isinstance(message, str):
|
if not api_token_request and not workspace and isinstance(message, str):
|
||||||
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
|
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
|
||||||
if _auto_workspace:
|
if _auto_workspace:
|
||||||
workspace = _auto_workspace
|
workspace = _auto_workspace
|
||||||
@@ -1309,6 +1343,8 @@ def setup_chat_routes(
|
|||||||
# mutable form fields, or a stale research_pending session marker,
|
# mutable form fields, or a stale research_pending session marker,
|
||||||
# consume the one-use grant on the unrelated research path.
|
# consume the one-use grant on the unrelated research path.
|
||||||
do_research = (
|
do_research = (
|
||||||
|
not api_token_request
|
||||||
|
and
|
||||||
not tool_approval_continuation
|
not tool_approval_continuation
|
||||||
and str(use_research).lower() == "true"
|
and str(use_research).lower() == "true"
|
||||||
)
|
)
|
||||||
@@ -1330,7 +1366,11 @@ def setup_chat_routes(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
||||||
|
|
||||||
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
|
image_generation_session = _is_image_generation_session(
|
||||||
|
sess, owner=effective_user(request)
|
||||||
|
)
|
||||||
|
if api_token_request and image_generation_session:
|
||||||
|
raise HTTPException(403, "API tokens cannot use image generation")
|
||||||
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
||||||
if image_generation_session:
|
if image_generation_session:
|
||||||
no_memory = True
|
no_memory = True
|
||||||
@@ -1845,7 +1885,7 @@ def setup_chat_routes(
|
|||||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||||
|
|
||||||
_terminal_saved = False
|
_terminal_saved = False
|
||||||
if _is_image_generation_session(sess, owner=_user):
|
if image_generation_session:
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
if tool_policy.blocks("generate_image"):
|
if tool_policy.blocks("generate_image"):
|
||||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||||
@@ -2256,7 +2296,7 @@ def setup_chat_routes(
|
|||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
allow_background_extraction=(
|
allow_background_extraction=(
|
||||||
not tool_policy.block_all_tool_calls
|
allow_tool_preprocessing
|
||||||
and not tool_approval_continuation
|
and not tool_approval_continuation
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2530,7 +2570,7 @@ def setup_chat_routes(
|
|||||||
and not tool_approval_continuation
|
and not tool_approval_continuation
|
||||||
),
|
),
|
||||||
allow_background_extraction=(
|
allow_background_extraction=(
|
||||||
not tool_policy.block_all_tool_calls
|
allow_tool_preprocessing
|
||||||
and not tool_approval_continuation
|
and not tool_approval_continuation
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2621,8 +2661,12 @@ def setup_chat_routes(
|
|||||||
# GET /api/chat/resume — reconnect to a detached run that's still going
|
# GET /api/chat/resume — reconnect to a detached run that's still going
|
||||||
# (e.g. after reopening a session whose agent kept running in the background)
|
# (e.g. after reopening a session whose agent kept running in the background)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.get("/api/chat/resume/{session_id}")
|
@router.get(
|
||||||
|
"/api/chat/resume/{session_id}",
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
||||||
|
require_interactive_request(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
_active_run = agent_runs.get_active_run(session_id)
|
_active_run = agent_runs.get_active_run(session_id)
|
||||||
if _active_run is None:
|
if _active_run is None:
|
||||||
@@ -2637,8 +2681,12 @@ def setup_chat_routes(
|
|||||||
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
|
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
|
||||||
# no longer stops it (it's detached), so the Stop button must call this.
|
# no longer stops it (it's detached), so the Stop button must call this.
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/chat/stop/{session_id}")
|
@router.post(
|
||||||
|
"/api/chat/stop/{session_id}",
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
||||||
|
require_interactive_request(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
||||||
stopped = agent_runs.stop(session_id, _expected_run_id)
|
stopped = agent_runs.stop(session_id, _expected_run_id)
|
||||||
@@ -2647,8 +2695,12 @@ def setup_chat_routes(
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# GET /api/chat/stream_status — check if a stream is active for a session
|
# GET /api/chat/stream_status — check if a stream is active for a session
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.get("/api/chat/stream_status/{session_id}")
|
@router.get(
|
||||||
|
"/api/chat/stream_status/{session_id}",
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]:
|
async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]:
|
||||||
|
require_interactive_request(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
# A detached run can still be going even if _active_streams was popped;
|
# A detached run can still be going even if _active_streams was popped;
|
||||||
# report it as active so the client knows to reconnect via /resume.
|
# report it as active so the client knows to reconnect via /resume.
|
||||||
@@ -2667,6 +2719,7 @@ def setup_chat_routes(
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/inject_context/{session_id}")
|
@router.post("/api/inject_context/{session_id}")
|
||||||
async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]:
|
async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]:
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
sess = session_manager.get_session(session_id)
|
sess = session_manager.get_session(session_id)
|
||||||
@@ -2686,6 +2739,7 @@ def setup_chat_routes(
|
|||||||
q: str = Query("", min_length=0),
|
q: str = Query("", min_length=0),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
require_chat_scope(request)
|
||||||
if not q or not q.strip():
|
if not q or not q.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -2711,6 +2765,7 @@ def setup_chat_routes(
|
|||||||
Unlike the full chat pipeline, this does NOT run the agent loop or tools.
|
Unlike the full chat pipeline, this does NOT run the agent loop or tools.
|
||||||
It just asks the LLM to rewrite the given text.
|
It just asks the LLM to rewrite the given text.
|
||||||
"""
|
"""
|
||||||
|
require_chat_scope(request)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+11
-13
@@ -16,7 +16,11 @@ from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.auth_helpers import require_authenticated_request, require_user
|
from src.auth_helpers import (
|
||||||
|
require_api_token_owner,
|
||||||
|
require_authenticated_request,
|
||||||
|
require_user,
|
||||||
|
)
|
||||||
from src.tool_implementations import do_manage_notes
|
from src.tool_implementations import do_manage_notes
|
||||||
from src.constants import COOKBOOK_STATE_FILE
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
@@ -84,29 +88,23 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
|||||||
|
|
||||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||||
"""Return the data owner if the caller is allowed for this Codex action."""
|
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||||
if getattr(request.state, "api_token", False):
|
if getattr(request.state, "api_token", False) is True:
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
if not scopes.intersection(allowed):
|
if not scopes.intersection(allowed):
|
||||||
required = " or ".join(sorted(allowed))
|
required = " or ".join(sorted(allowed))
|
||||||
raise HTTPException(403, f"API token missing required scope: {required}")
|
raise HTTPException(403, f"API token missing required scope: {required}")
|
||||||
owner = getattr(request.state, "api_token_owner", None)
|
return require_api_token_owner(request)
|
||||||
if not owner:
|
|
||||||
raise HTTPException(403, "API token has no owner")
|
|
||||||
return owner
|
|
||||||
return require_user(request)
|
return require_user(request)
|
||||||
|
|
||||||
|
|
||||||
def _scope_owner_all(request: Request, required: set[str]) -> str:
|
def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||||
"""Return owner only when an API token has every required scope."""
|
"""Return owner only when an API token has every required scope."""
|
||||||
if getattr(request.state, "api_token", False):
|
if getattr(request.state, "api_token", False) is True:
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
missing = required - scopes
|
missing = required - scopes
|
||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
|
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
|
||||||
owner = getattr(request.state, "api_token_owner", None)
|
return require_api_token_owner(request)
|
||||||
if not owner:
|
|
||||||
raise HTTPException(403, "API token has no owner")
|
|
||||||
return owner
|
|
||||||
return require_user(request)
|
return require_user(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +117,7 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
|||||||
commands, and model-serving controls.
|
commands, and model-serving controls.
|
||||||
"""
|
"""
|
||||||
owner = _scope_owner(request, allowed)
|
owner = _scope_owner(request, allowed)
|
||||||
if not getattr(request.state, "api_token", False):
|
if getattr(request.state, "api_token", False) is not True:
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
return owner
|
return owner
|
||||||
|
|
||||||
@@ -167,7 +165,7 @@ def setup_codex_routes(
|
|||||||
@router.get("/capabilities")
|
@router.get("/capabilities")
|
||||||
def capabilities(request: Request):
|
def capabilities(request: Request):
|
||||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
has_token = bool(getattr(request.state, "api_token", False))
|
has_token = getattr(request.state, "api_token", False) is True
|
||||||
def scoped(allowed):
|
def scoped(allowed):
|
||||||
return bool(token_scopes.intersection(allowed)) if has_token else True
|
return bool(token_scopes.intersection(allowed)) if has_token else True
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: Optional[int] = None,
|
offset: Optional[int] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
page_limit = max(1, min(int(limit), 100))
|
page_limit = max(1, min(int(limit), 100))
|
||||||
@@ -247,6 +248,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
|
|
||||||
@router.post("/api/session/{session_id}/truncate")
|
@router.post("/api/session/{session_id}/truncate")
|
||||||
async def truncate_session(request: Request, session_id: str):
|
async def truncate_session(request: Request, session_id: str):
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -262,6 +264,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/message")
|
@router.post("/api/session/{session_id}/message")
|
||||||
async def add_message(request: Request, session_id: str):
|
async def add_message(request: Request, session_id: str):
|
||||||
"""Add a message to a session (for slash command persistence)."""
|
"""Add a message to a session (for slash command persistence)."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -280,6 +283,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/delete-messages")
|
@router.post("/api/session/{session_id}/delete-messages")
|
||||||
async def delete_messages(request: Request, session_id: str):
|
async def delete_messages(request: Request, session_id: str):
|
||||||
"""Delete specific messages by DB ID (or legacy index)."""
|
"""Delete specific messages by DB ID (or legacy index)."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -343,6 +347,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/edit-message")
|
@router.post("/api/session/{session_id}/edit-message")
|
||||||
async def edit_message(request: Request, session_id: str):
|
async def edit_message(request: Request, session_id: str):
|
||||||
"""Edit the content of a message by its database ID."""
|
"""Edit the content of a message by its database ID."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -398,6 +403,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/mark-stopped")
|
@router.post("/api/session/{session_id}/mark-stopped")
|
||||||
async def mark_stopped(request: Request, session_id: str):
|
async def mark_stopped(request: Request, session_id: str):
|
||||||
"""Mark the last assistant message as stopped by user."""
|
"""Mark the last assistant message as stopped by user."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
@@ -453,10 +459,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/update-last-meta")
|
@router.post("/api/session/{session_id}/update-last-meta")
|
||||||
async def update_last_meta(request: Request, session_id: str):
|
async def update_last_meta(request: Request, session_id: str):
|
||||||
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
meta_update = sanitize_client_message_metadata(body.get("metadata", {}))
|
meta_update = sanitize_client_message_metadata(body.get("metadata", {})) or {}
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
|
|
||||||
# Update in-memory
|
# Update in-memory
|
||||||
@@ -504,6 +511,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/merge-last-assistant")
|
@router.post("/api/session/{session_id}/merge-last-assistant")
|
||||||
async def merge_last_assistant(request: Request, session_id: str):
|
async def merge_last_assistant(request: Request, session_id: str):
|
||||||
"""Merge the last two assistant messages into one (for continue)."""
|
"""Merge the last two assistant messages into one (for continue)."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -593,6 +601,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/fork")
|
@router.post("/api/session/{session_id}/fork")
|
||||||
async def fork_session(request: Request, session_id: str):
|
async def fork_session(request: Request, session_id: str):
|
||||||
"""Create a new session with messages copied up to keep_count."""
|
"""Create a new session with messages copied up to keep_count."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -651,6 +660,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
|
|
||||||
@router.get("/api/conversations/topics")
|
@router.get("/api/conversations/topics")
|
||||||
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
||||||
|
require_chat_scope(request)
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import require_user
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
try:
|
try:
|
||||||
@@ -666,6 +676,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
endpoint estimates the persisted session context so the header can show
|
endpoint estimates the persisted session context so the header can show
|
||||||
when the whole chat is approaching compaction.
|
when the whole chat is approaching compaction.
|
||||||
"""
|
"""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
@@ -710,6 +721,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/api/session/{session_id}/compact")
|
@router.post("/api/session/{session_id}/compact")
|
||||||
async def compact_session(request: Request, session_id: str):
|
async def compact_session(request: Request, session_id: str):
|
||||||
"""Manually trigger context compaction for a session."""
|
"""Manually trigger context compaction for a session."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from src.endpoint_resolver import (
|
|||||||
build_models_url,
|
build_models_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
)
|
)
|
||||||
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
|
from src.auth_helpers import _auth_disabled, owner_filter, require_chat_scope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1622,13 +1622,7 @@ def setup_model_routes(model_discovery):
|
|||||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||||
# "see everything" by _fetch_models.
|
# "see everything" by _fetch_models.
|
||||||
try:
|
try:
|
||||||
if getattr(request.state, "api_token", False):
|
owner = require_chat_scope(request) or ""
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
|
||||||
if "chat" not in scopes:
|
|
||||||
raise HTTPException(403, "API token is not scoped for chat")
|
|
||||||
if not getattr(request.state, "api_token_owner", None):
|
|
||||||
raise HTTPException(403, "API token has no owner")
|
|
||||||
owner = effective_user(request) or ""
|
|
||||||
|
|
||||||
# Reject anonymous in configured deployments — no leaking the model
|
# Reject anonymous in configured deployments — no leaking the model
|
||||||
# list to unauthenticated callers.
|
# list to unauthenticated callers.
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from core.middleware import INTERNAL_TOOL_USER
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, require_interactive_request
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
@@ -207,14 +207,17 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
|
|
||||||
|
|
||||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||||
router = APIRouter(tags=["research"])
|
router = APIRouter(
|
||||||
|
tags=["research"],
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
|
|
||||||
def _require_user(request: Request) -> str:
|
def _require_user(request: Request) -> str:
|
||||||
"""All research endpoints require an authenticated user. Research
|
"""All research endpoints require an authenticated user. Research
|
||||||
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
||||||
block anonymous access. Multi-tenant deploys should additionally
|
block anonymous access. Multi-tenant deploys should additionally
|
||||||
verify the session belongs to this user."""
|
verify the session belongs to this user."""
|
||||||
user = get_current_user(request)
|
user = require_interactive_request(request)
|
||||||
if not user:
|
if not user:
|
||||||
if _auth_disabled():
|
if _auth_disabled():
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||||
from services.search.core import _call_provider
|
from services.search.core import _call_provider
|
||||||
from services.search.providers import _get_provider_key, _get_search_instance
|
from services.search.providers import _get_provider_key, _get_search_instance
|
||||||
|
from src.auth_helpers import require_chat_scope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,7 +38,10 @@ async def _request_values(request: Request) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def setup_search_routes(config) -> APIRouter:
|
def setup_search_routes(config) -> APIRouter:
|
||||||
router = APIRouter(tags=["search"])
|
router = APIRouter(
|
||||||
|
tags=["search"],
|
||||||
|
dependencies=[Depends(require_chat_scope)],
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/search/config")
|
@router.get("/api/search/config")
|
||||||
async def get_search_settings() -> Dict[str, Any]:
|
async def get_search_settings() -> Dict[str, Any]:
|
||||||
@@ -49,6 +53,7 @@ def setup_search_routes(config) -> APIRouter:
|
|||||||
|
|
||||||
Used by Compare mode to pre-search once and share results across panes.
|
Used by Compare mode to pre-search once and share results across panes.
|
||||||
"""
|
"""
|
||||||
|
require_chat_scope(request)
|
||||||
values = await _request_values(request)
|
values = await _request_values(request)
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
@@ -87,6 +92,7 @@ def setup_search_routes(config) -> APIRouter:
|
|||||||
@router.post("/api/search/query")
|
@router.post("/api/search/query")
|
||||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||||
"""Search using a specific provider. Used by compare search mode."""
|
"""Search using a specific provider. Used by compare search mode."""
|
||||||
|
require_chat_scope(request)
|
||||||
values = await _request_values(request)
|
values = await _request_values(request)
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
provider = str(values.get("provider") or "").strip()
|
provider = str(values.get("provider") or "").strip()
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ def setup_session_routes(
|
|||||||
|
|
||||||
@router.get("/sessions")
|
@router.get("/sessions")
|
||||||
def list_sessions(request: Request):
|
def list_sessions(request: Request):
|
||||||
|
require_chat_scope(request)
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
|
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
|
||||||
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
||||||
@@ -343,6 +344,7 @@ def setup_session_routes(
|
|||||||
api_key: str = Form(""),
|
api_key: str = Form(""),
|
||||||
endpoint_id: str = Form(""),
|
endpoint_id: str = Form(""),
|
||||||
):
|
):
|
||||||
|
require_chat_scope(request)
|
||||||
skip_val = str(skip_validation).lower() == "true"
|
skip_val = str(skip_validation).lower() == "true"
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
endpoint_api_key = ""
|
endpoint_api_key = ""
|
||||||
@@ -470,6 +472,7 @@ def setup_session_routes(
|
|||||||
model: str = Form(None), endpoint_url: str = Form(None),
|
model: str = Form(None), endpoint_url: str = Form(None),
|
||||||
endpoint_id: str = Form(None),
|
endpoint_id: str = Form(None),
|
||||||
):
|
):
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid)
|
_verify_session_owner(request, sid)
|
||||||
try:
|
try:
|
||||||
session = session_manager.get_session(sid)
|
session = session_manager.get_session(sid)
|
||||||
@@ -544,6 +547,7 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{sid}/inject_messages")
|
@router.post("/session/{sid}/inject_messages")
|
||||||
async def inject_messages(request: Request, sid: str):
|
async def inject_messages(request: Request, sid: str):
|
||||||
"""Bulk-inject messages into a session's history (for group chat sync)."""
|
"""Bulk-inject messages into a session's history (for group chat sync)."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid)
|
_verify_session_owner(request, sid)
|
||||||
try:
|
try:
|
||||||
sess = session_manager.get_session(sid)
|
sess = session_manager.get_session(sid)
|
||||||
@@ -580,11 +584,13 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{sid}/delete")
|
@router.post("/session/{sid}/delete")
|
||||||
def delete_session_beacon(request: Request, sid: str):
|
def delete_session_beacon(request: Request, sid: str):
|
||||||
"""Delete session via POST (for navigator.sendBeacon on page close)."""
|
"""Delete session via POST (for navigator.sendBeacon on page close)."""
|
||||||
|
require_chat_scope(request)
|
||||||
return delete_session(request, sid)
|
return delete_session(request, sid)
|
||||||
|
|
||||||
@router.post("/sessions/bulk-delete")
|
@router.post("/sessions/bulk-delete")
|
||||||
async def bulk_delete_sessions(request: Request):
|
async def bulk_delete_sessions(request: Request):
|
||||||
"""Delete multiple sessions (for compare cleanup via sendBeacon)."""
|
"""Delete multiple sessions (for compare cleanup via sendBeacon)."""
|
||||||
|
require_chat_scope(request)
|
||||||
from core.database import ChatMessage as _CM
|
from core.database import ChatMessage as _CM
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -614,6 +620,7 @@ def setup_session_routes(
|
|||||||
@router.delete("/session/{sid}")
|
@router.delete("/session/{sid}")
|
||||||
def delete_session(request: Request, sid: str):
|
def delete_session(request: Request, sid: str):
|
||||||
"""Permanently delete a session and all its messages."""
|
"""Permanently delete a session and all its messages."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid, session_manager)
|
_verify_session_owner(request, sid, session_manager)
|
||||||
try:
|
try:
|
||||||
# Block deletion of starred/favorited sessions
|
# Block deletion of starred/favorited sessions
|
||||||
@@ -648,6 +655,7 @@ def setup_session_routes(
|
|||||||
@router.delete("/sessions/all")
|
@router.delete("/sessions/all")
|
||||||
def delete_all_sessions(request: Request):
|
def delete_all_sessions(request: Request):
|
||||||
"""Admin only: permanently delete ALL sessions and their messages."""
|
"""Admin only: permanently delete ALL sessions and their messages."""
|
||||||
|
require_chat_scope(request)
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|
||||||
@@ -701,6 +709,7 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{sid}/archive")
|
@router.post("/session/{sid}/archive")
|
||||||
def archive_session(request: Request, sid: str):
|
def archive_session(request: Request, sid: str):
|
||||||
"""Archive a session, keeping its data but removing it from active sessions."""
|
"""Archive a session, keeping its data but removing it from active sessions."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid)
|
_verify_session_owner(request, sid)
|
||||||
try:
|
try:
|
||||||
# First check if session exists
|
# First check if session exists
|
||||||
@@ -739,6 +748,7 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{sid}/unarchive")
|
@router.post("/session/{sid}/unarchive")
|
||||||
def unarchive_session(request: Request, sid: str):
|
def unarchive_session(request: Request, sid: str):
|
||||||
"""Restore an archived session back to the active session list."""
|
"""Restore an archived session back to the active session list."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid)
|
_verify_session_owner(request, sid)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -769,6 +779,7 @@ def setup_session_routes(
|
|||||||
@router.get("/sessions/archived")
|
@router.get("/sessions/archived")
|
||||||
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""):
|
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""):
|
||||||
"""List archived sessions for the archive browser."""
|
"""List archived sessions for the archive browser."""
|
||||||
|
require_chat_scope(request)
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -816,6 +827,7 @@ def setup_session_routes(
|
|||||||
|
|
||||||
Supported formats: md (markdown), txt (plain text), json, html
|
Supported formats: md (markdown), txt (plain text), json, html
|
||||||
"""
|
"""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, sid)
|
_verify_session_owner(request, sid)
|
||||||
try:
|
try:
|
||||||
session = session_manager.get_session(sid)
|
session = session_manager.get_session(sid)
|
||||||
@@ -902,6 +914,7 @@ def setup_session_routes(
|
|||||||
|
|
||||||
@router.post("/sessions/save")
|
@router.post("/sessions/save")
|
||||||
def sessions_save_now(request: Request):
|
def sessions_save_now(request: Request):
|
||||||
|
require_chat_scope(request)
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
@@ -915,6 +928,7 @@ def setup_session_routes(
|
|||||||
model: str = Form("gpt-4o"),
|
model: str = Form("gpt-4o"),
|
||||||
rag: str = Form(None)
|
rag: str = Form(None)
|
||||||
):
|
):
|
||||||
|
require_chat_scope(request)
|
||||||
if not OPENAI_API_KEY:
|
if not OPENAI_API_KEY:
|
||||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||||
sid = str(uuid.uuid4())
|
sid = str(uuid.uuid4())
|
||||||
@@ -936,6 +950,7 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{session_id}/important")
|
@router.post("/session/{session_id}/important")
|
||||||
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)):
|
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)):
|
||||||
"""Mark a session as important to protect it from automatic cleanup."""
|
"""Mark a session as important to protect it from automatic cleanup."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
# Validate session exists
|
# Validate session exists
|
||||||
@@ -973,6 +988,7 @@ def setup_session_routes(
|
|||||||
@router.post("/session/{session_id}/compact")
|
@router.post("/session/{session_id}/compact")
|
||||||
async def compact_session(request: Request, session_id: str):
|
async def compact_session(request: Request, session_id: str):
|
||||||
"""Summarize older messages into one compacted history entry."""
|
"""Summarize older messages into one compacted history entry."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
@@ -1059,6 +1075,7 @@ def setup_session_routes(
|
|||||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||||
users can clean junk without spending tokens.
|
users can clean junk without spending tokens.
|
||||||
"""
|
"""
|
||||||
|
require_chat_scope(request)
|
||||||
from src.llm_core import llm_call
|
from src.llm_core import llm_call
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
@@ -1339,6 +1356,7 @@ def setup_session_routes(
|
|||||||
@router.get("/session/{session_id}/context_info")
|
@router.get("/session/{session_id}/context_info")
|
||||||
async def get_context_info(request: Request, session_id: str):
|
async def get_context_info(request: Request, session_id: str):
|
||||||
"""Get the real context length for a session's model from the endpoint."""
|
"""Get the real context length for a session's model from the endpoint."""
|
||||||
|
require_chat_scope(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from typing import List, Optional
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import require_interactive_request
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
@@ -1181,10 +1181,14 @@ async def run_scheduled_skill_audit(skills_manager: SkillsManager,
|
|||||||
|
|
||||||
|
|
||||||
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/skills", tags=["skills"])
|
router = APIRouter(
|
||||||
|
prefix="/api/skills",
|
||||||
|
tags=["skills"],
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
|
|
||||||
def _owner(request: Request) -> Optional[str]:
|
def _owner(request: Request) -> Optional[str]:
|
||||||
return get_current_user(request)
|
return require_interactive_request(request)
|
||||||
|
|
||||||
def _verify_owner(skill: dict, user: Optional[str]):
|
def _verify_owner(skill: dict, user: Optional[str]):
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user, require_interactive_request
|
||||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
ADMIN_ONLY_TASK_ACTIONS,
|
ADMIN_ONLY_TASK_ACTIONS,
|
||||||
@@ -296,9 +296,17 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def setup_task_routes(task_scheduler) -> APIRouter:
|
def setup_task_routes(task_scheduler) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
router = APIRouter(
|
||||||
|
prefix="/api/tasks",
|
||||||
|
tags=["tasks"],
|
||||||
|
dependencies=[Depends(require_interactive_request)],
|
||||||
|
)
|
||||||
|
|
||||||
def _owner(request: Request):
|
def _owner(request: Request):
|
||||||
|
# Keep the route-local user lookup injectable for direct handler tests
|
||||||
|
# and legacy callers, but always run the centralized bearer-principal
|
||||||
|
# rejection first.
|
||||||
|
require_interactive_request(request)
|
||||||
return get_current_user(request)
|
return get_current_user(request)
|
||||||
|
|
||||||
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
|
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
session_id: Optional[str] = Form(None),
|
session_id: Optional[str] = Form(None),
|
||||||
):
|
):
|
||||||
"""Upload files with enhanced security and organization."""
|
"""Upload files with enhanced security and organization."""
|
||||||
|
require_chat_scope(request)
|
||||||
if not isinstance(session_id, str):
|
if not isinstance(session_id, str):
|
||||||
session_id = None
|
session_id = None
|
||||||
if not files:
|
if not files:
|
||||||
@@ -324,6 +325,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
@router.post("/cleanup")
|
@router.post("/cleanup")
|
||||||
async def manual_cleanup(request: Request):
|
async def manual_cleanup(request: Request):
|
||||||
"""Manually trigger cleanup of old uploads."""
|
"""Manually trigger cleanup of old uploads."""
|
||||||
|
require_chat_scope(request)
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
try:
|
try:
|
||||||
cleaned_count = await asyncio.to_thread(
|
cleaned_count = await asyncio.to_thread(
|
||||||
@@ -347,6 +349,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def upload_stats(request: Request):
|
async def upload_stats(request: Request):
|
||||||
"""Get statistics about uploaded files."""
|
"""Get statistics about uploaded files."""
|
||||||
|
require_chat_scope(request)
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
try:
|
try:
|
||||||
return upload_handler.get_upload_stats()
|
return upload_handler.get_upload_stats()
|
||||||
@@ -359,6 +362,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
||||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
JPEG thumbnail for images (used by chat attachment previews) so the
|
||||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
client isn't downloading the full-resolution photo just to show it tiny."""
|
||||||
|
require_chat_scope(request)
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
import mimetypes as _mt
|
import mimetypes as _mt
|
||||||
@@ -457,6 +461,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
"""Return the vision-model OCR/description for an uploaded image.
|
"""Return the vision-model OCR/description for an uploaded image.
|
||||||
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
||||||
subsequent loads are instant. Pass force=1 to recompute."""
|
subsequent loads are instant. Pass force=1 to recompute."""
|
||||||
|
require_chat_scope(request)
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
info = _load_upload_info(file_id)
|
info = _load_upload_info(file_id)
|
||||||
@@ -501,6 +506,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
async def put_vision_text(request: Request, file_id: str):
|
async def put_vision_text(request: Request, file_id: str):
|
||||||
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
||||||
the same cache file so the chat send picks it up as the override."""
|
the same cache file so the chat send picks it up as the override."""
|
||||||
|
require_chat_scope(request)
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
info = _load_upload_info(file_id)
|
info = _load_upload_info(file_id)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter, require_chat_scope
|
||||||
from src.url_security import validate_public_http_url
|
from src.url_security import validate_public_http_url
|
||||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||||
|
|
||||||
@@ -32,14 +32,15 @@ def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
|||||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||||
let a chat-scoped token fall back onto another user's private endpoint and
|
let a chat-scoped token fall back onto another user's private endpoint and
|
||||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
rows. Fails closed when token_owner is absent; the sync endpoint requires
|
||||||
|
an owner-scoped bearer before this helper is reached.
|
||||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||||
"""
|
"""
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||||
if token_owner:
|
if not token_owner:
|
||||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
return None
|
||||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||||
|
|
||||||
|
|
||||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||||
@@ -236,12 +237,11 @@ def setup_webhook_routes(
|
|||||||
|
|
||||||
@router.post("/v1/chat")
|
@router.post("/v1/chat")
|
||||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||||
if not getattr(request.state, "api_token", False):
|
if getattr(request.state, "api_token", False) is not True:
|
||||||
raise HTTPException(403, "This endpoint requires an API token")
|
raise HTTPException(403, "This endpoint requires an API token")
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
token_owner = require_chat_scope(request)
|
||||||
if "chat" not in scopes:
|
if not token_owner:
|
||||||
raise HTTPException(403, "API token is not scoped for chat")
|
raise HTTPException(403, "API token has no owner")
|
||||||
token_owner = getattr(request.state, "api_token_owner", None)
|
|
||||||
|
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
|||||||
+64
-14
@@ -4,12 +4,17 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import Request, HTTPException
|
||||||
|
|
||||||
from src.owner_identity import auth_disabled, effective_storage_owner
|
from src.owner_identity import (
|
||||||
|
auth_disabled,
|
||||||
|
effective_storage_owner,
|
||||||
|
is_request_sentinel_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> Optional[str]:
|
def get_current_user(request: Request) -> Optional[str]:
|
||||||
"""Get current username from request state (set by auth middleware)."""
|
"""Get current username from request state (set by auth middleware)."""
|
||||||
return getattr(request.state, 'current_user', None)
|
state = getattr(request, "state", None)
|
||||||
|
return getattr(state, "current_user", None)
|
||||||
|
|
||||||
|
|
||||||
def effective_user(request: Request) -> Optional[str]:
|
def effective_user(request: Request) -> Optional[str]:
|
||||||
@@ -29,29 +34,58 @@ def effective_user(request: Request) -> Optional[str]:
|
|||||||
owner falls back to :func:`get_current_user` (the "api" pseudo-user), so it
|
owner falls back to :func:`get_current_user` (the "api" pseudo-user), so it
|
||||||
never escalates.
|
never escalates.
|
||||||
"""
|
"""
|
||||||
if getattr(request.state, "api_token", False):
|
if _is_api_token_request(request):
|
||||||
owner = getattr(request.state, "api_token_owner", None)
|
state = getattr(request, "state", None)
|
||||||
if owner:
|
owner = getattr(state, "api_token_owner", None)
|
||||||
return owner
|
if isinstance(owner, str) and owner.strip():
|
||||||
|
return owner.strip()
|
||||||
return get_current_user(request)
|
return get_current_user(request)
|
||||||
|
|
||||||
|
|
||||||
def _is_api_token_request(request: Request) -> bool:
|
def _is_api_token_request(request: Request) -> bool:
|
||||||
"""Return True when middleware authenticated a bearer API token."""
|
"""Return True when middleware authenticated a bearer API token."""
|
||||||
return bool(getattr(request.state, "api_token", False))
|
state = getattr(request, "state", None)
|
||||||
|
return getattr(state, "api_token", False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_token_owner(request: Request) -> str:
|
||||||
|
"""Return a real owner for a bearer request, failing closed otherwise.
|
||||||
|
|
||||||
|
The middleware normally resolves token owners against configured human
|
||||||
|
accounts. Keep that invariant at route boundaries too: direct endpoint
|
||||||
|
tests, alternate ASGI entry points, and future middleware changes must not
|
||||||
|
turn a request sentinel or an ownerless token into a durable/executable
|
||||||
|
owner.
|
||||||
|
"""
|
||||||
|
state = getattr(request, "state", None)
|
||||||
|
owner = getattr(state, "api_token_owner", None)
|
||||||
|
if (
|
||||||
|
not isinstance(owner, str)
|
||||||
|
or not owner.strip()
|
||||||
|
or is_request_sentinel_owner(owner)
|
||||||
|
):
|
||||||
|
raise HTTPException(403, "API token has no owner")
|
||||||
|
return owner.strip()
|
||||||
|
|
||||||
|
|
||||||
def require_api_token_scope(request: Request, required_scope: str) -> Optional[str]:
|
def require_api_token_scope(request: Request, required_scope: str) -> Optional[str]:
|
||||||
"""Require one declared scope for bearer callers; leave browser callers unchanged."""
|
"""Require one declared scope for bearer callers; leave browser callers unchanged."""
|
||||||
if not _is_api_token_request(request):
|
if not _is_api_token_request(request):
|
||||||
return effective_user(request)
|
return effective_user(request)
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
state = getattr(request, "state", None)
|
||||||
if required_scope not in scopes:
|
raw_scopes = getattr(state, "api_token_scopes", None)
|
||||||
|
if isinstance(raw_scopes, (list, tuple, set, frozenset)):
|
||||||
|
scopes = {
|
||||||
|
value.strip().casefold()
|
||||||
|
for value in raw_scopes
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
scopes = set()
|
||||||
|
normalized_scope = str(required_scope or "").strip().casefold()
|
||||||
|
if not normalized_scope or normalized_scope not in scopes:
|
||||||
raise HTTPException(403, f"API token missing required scope: {required_scope}")
|
raise HTTPException(403, f"API token missing required scope: {required_scope}")
|
||||||
owner = getattr(request.state, "api_token_owner", None)
|
return require_api_token_owner(request)
|
||||||
if not owner:
|
|
||||||
raise HTTPException(403, "API token has no owner")
|
|
||||||
return owner
|
|
||||||
|
|
||||||
|
|
||||||
def require_chat_scope(request: Request) -> Optional[str]:
|
def require_chat_scope(request: Request) -> Optional[str]:
|
||||||
@@ -59,6 +93,22 @@ def require_chat_scope(request: Request) -> Optional[str]:
|
|||||||
return require_api_token_scope(request, "chat")
|
return require_api_token_scope(request, "chat")
|
||||||
|
|
||||||
|
|
||||||
|
def require_interactive_request(request: Request) -> Optional[str]:
|
||||||
|
"""Reject bearer integrations from browser-only agent/control surfaces.
|
||||||
|
|
||||||
|
This is deliberately a bearer-principal gate rather than an authentication
|
||||||
|
requirement. Cookie sessions and AUTH_ENABLED=false keep their existing
|
||||||
|
route behavior, while API tokens cannot enter routes that start, resume,
|
||||||
|
approve, or otherwise control interactive agent work.
|
||||||
|
"""
|
||||||
|
current_user = get_current_user(request)
|
||||||
|
if _is_api_token_request(request) or (
|
||||||
|
isinstance(current_user, str) and current_user.strip().casefold() == "api"
|
||||||
|
):
|
||||||
|
raise HTTPException(403, "API tokens cannot use this interactive surface")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def enforce_api_token_chat_controls(
|
def enforce_api_token_chat_controls(
|
||||||
request: Request,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
@@ -88,7 +138,7 @@ def require_authenticated_request(request: Request) -> str:
|
|||||||
sessions or their own API-token scope/owner gate.
|
sessions or their own API-token scope/owner gate.
|
||||||
"""
|
"""
|
||||||
if _is_api_token_request(request):
|
if _is_api_token_request(request):
|
||||||
return effective_user(request) or ""
|
return require_api_token_owner(request)
|
||||||
return require_user(request)
|
return require_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class ChatProcessor:
|
|||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
incognito: bool = False,
|
incognito: bool = False,
|
||||||
use_skills: bool = True,
|
use_skills: bool = True,
|
||||||
|
allow_tool_preprocessing: bool = True,
|
||||||
) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, str]]]:
|
) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, str]]]:
|
||||||
"""Build the context preface for LLM calls.
|
"""Build the context preface for LLM calls.
|
||||||
|
|
||||||
@@ -457,7 +458,7 @@ class ChatProcessor:
|
|||||||
# hundreds of KB of duplicate page HTML and confuses the model) or for
|
# hundreds of KB of duplicate page HTML and confuses the model) or for
|
||||||
# link-heavy pastes (>3 URLs typically means it's a boilerplate-laden
|
# link-heavy pastes (>3 URLs typically means it's a boilerplate-laden
|
||||||
# blog post, not a "summarize this URL" request).
|
# blog post, not a "summarize this URL" request).
|
||||||
urls = extract_urls(message)
|
urls = extract_urls(message) if allow_tool_preprocessing else []
|
||||||
non_yt_urls = [u for u in urls if not is_youtube_url(u)]
|
non_yt_urls = [u for u in urls if not is_youtube_url(u)]
|
||||||
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
|
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
|
||||||
if not skip_url_fetch:
|
if not skip_url_fetch:
|
||||||
|
|||||||
+15
-5
@@ -1,6 +1,6 @@
|
|||||||
"""Trust-boundary helpers for client-supplied chat metadata."""
|
"""Trust-boundary helpers for client-supplied chat metadata."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
|
|
||||||
@@ -11,12 +11,22 @@ _SERVER_OWNED_MESSAGE_METADATA = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def sanitize_client_message_metadata(metadata: Any) -> Any:
|
def sanitize_client_message_metadata(metadata: Any) -> Optional[dict]:
|
||||||
"""Drop fields that can only be produced by server-side tool execution."""
|
"""Normalize client metadata and drop server-owned fields.
|
||||||
|
|
||||||
|
Client metadata is only a JSON object. In particular, do not let a
|
||||||
|
list-of-pairs value reach ``dict.update``: that mapping-compatible shape
|
||||||
|
can smuggle protected approval fields through an otherwise safe merge.
|
||||||
|
Malformed metadata is normalized away; server-generated metadata remains
|
||||||
|
untouched because this helper is called only at client ingress points.
|
||||||
|
"""
|
||||||
|
if metadata is None:
|
||||||
|
return None
|
||||||
if not isinstance(metadata, dict):
|
if not isinstance(metadata, dict):
|
||||||
return metadata
|
return None
|
||||||
return {
|
sanitized = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in metadata.items()
|
for key, value in metadata.items()
|
||||||
if key not in _SERVER_OWNED_MESSAGE_METADATA
|
if key not in _SERVER_OWNED_MESSAGE_METADATA
|
||||||
}
|
}
|
||||||
|
return sanitized or None
|
||||||
|
|||||||
@@ -327,6 +327,53 @@ async def test_api_chat_direct_base_url_allows_mocked_public_endpoint(monkeypatc
|
|||||||
assert session_manager.created[0]["endpoint_url"] == "https://api.example.com/v1/chat/completions"
|
assert session_manager.created[0]["endpoint_url"] == "https://api.example.com/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_chat_ownerless_token_cannot_use_direct_api_key(monkeypatch):
|
||||||
|
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
||||||
|
_install_sync_chat_stubs(monkeypatch)
|
||||||
|
session_manager = _SessionManager()
|
||||||
|
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
|
||||||
|
body = types.SimpleNamespace(
|
||||||
|
message="hello",
|
||||||
|
api_key="test-key",
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
model="test-model",
|
||||||
|
provider=None,
|
||||||
|
session=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(webhook_routes.HTTPException) as exc:
|
||||||
|
await sync_chat(_Request(owner=None), body)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert session_manager.created == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_chat_ownerless_token_cannot_use_configured_fallback(monkeypatch):
|
||||||
|
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
||||||
|
_install_sync_chat_stubs(monkeypatch)
|
||||||
|
db = _DB([_Endpoint(owner=None, base_url="http://localhost:11434/v1", api_key="shared-key")])
|
||||||
|
monkeypatch.setattr(webhook_routes, "ModelEndpoint", _ModelEndpoint)
|
||||||
|
monkeypatch.setattr(webhook_routes, "SessionLocal", lambda: db)
|
||||||
|
session_manager = _SessionManager()
|
||||||
|
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
|
||||||
|
body = types.SimpleNamespace(
|
||||||
|
message="hello",
|
||||||
|
api_key=None,
|
||||||
|
base_url=None,
|
||||||
|
model="local-model",
|
||||||
|
provider=None,
|
||||||
|
session=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(webhook_routes.HTTPException) as exc:
|
||||||
|
await sync_chat(_Request(owner=None), body)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert session_manager.created == []
|
||||||
|
|
||||||
|
|
||||||
def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
|
def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
|
||||||
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
||||||
rows = [
|
rows = [
|
||||||
@@ -345,7 +392,7 @@ def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
|
|||||||
assert selected.created_at == 2
|
assert selected.created_at == 2
|
||||||
|
|
||||||
|
|
||||||
def test_api_chat_fallback_without_owner_uses_shared_only(monkeypatch):
|
def test_api_chat_fallback_without_owner_is_not_selectable(monkeypatch):
|
||||||
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
||||||
rows = [
|
rows = [
|
||||||
_Endpoint(owner="alice", created_at=0),
|
_Endpoint(owner="alice", created_at=0),
|
||||||
@@ -357,9 +404,7 @@ def test_api_chat_fallback_without_owner_uses_shared_only(monkeypatch):
|
|||||||
|
|
||||||
selected = webhook_routes._select_api_chat_fallback_endpoint(_DB(rows), None)
|
selected = webhook_routes._select_api_chat_fallback_endpoint(_DB(rows), None)
|
||||||
|
|
||||||
assert selected.owner is None
|
assert selected is None
|
||||||
assert selected.is_enabled is True
|
|
||||||
assert selected.created_at == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -367,7 +412,7 @@ async def test_api_chat_fallback_trusts_configured_local_endpoint(monkeypatch):
|
|||||||
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
|
||||||
_install_sync_chat_stubs(monkeypatch)
|
_install_sync_chat_stubs(monkeypatch)
|
||||||
local_endpoint = _Endpoint(
|
local_endpoint = _Endpoint(
|
||||||
owner=None,
|
owner="alice",
|
||||||
base_url="http://localhost:11434/v1",
|
base_url="http://localhost:11434/v1",
|
||||||
api_key="configured-key",
|
api_key="configured-key",
|
||||||
)
|
)
|
||||||
@@ -396,7 +441,7 @@ async def test_api_chat_fallback_trusts_configured_local_endpoint(monkeypatch):
|
|||||||
session=None,
|
session=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await sync_chat(_Request(owner=None), body)
|
response = await sync_chat(_Request(owner="alice"), body)
|
||||||
|
|
||||||
assert response["response"] == "mocked response"
|
assert response["response"] == "mocked response"
|
||||||
assert response["model"] == "local-model"
|
assert response["model"] == "local-model"
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
"""Regression coverage for the API-token forward-fix boundary.
|
||||||
|
|
||||||
|
These tests deliberately exercise both sides of FastAPI's router dependency
|
||||||
|
boundary: real ASGI requests run router dependencies, while direct endpoint
|
||||||
|
calls must still hit the same security helper before doing work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from core.models import ChatMessage, Session
|
||||||
|
from src.auth_helpers import (
|
||||||
|
effective_user,
|
||||||
|
require_api_token_owner,
|
||||||
|
require_chat_scope,
|
||||||
|
require_interactive_request,
|
||||||
|
)
|
||||||
|
from src.message_metadata import sanitize_client_message_metadata
|
||||||
|
from src.request_models import ChatRequest
|
||||||
|
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
|
|
||||||
|
|
||||||
|
def _request(*, api_token=True, owner="alice", scopes=("chat",), current_user="api"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
api_token=api_token,
|
||||||
|
api_token_owner=owner,
|
||||||
|
api_token_scopes=list(scopes),
|
||||||
|
current_user=current_user,
|
||||||
|
),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_scope_gate_requires_a_real_owner_and_normalizes_scope_input():
|
||||||
|
assert require_chat_scope(
|
||||||
|
_request(owner=" alice ", scopes=(" CHAT ",))
|
||||||
|
) == "alice"
|
||||||
|
assert effective_user(_request(owner=" alice ")) == "alice"
|
||||||
|
|
||||||
|
for owner in (None, "", " "):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_chat_scope(_request(owner=owner))
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_chat_scope(_request(scopes=(["invalid"],)))
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("owner", ["api", "internal-tool", "SYSTEM", " ", None])
|
||||||
|
def test_bearer_owner_gate_rejects_sentinels_and_ownerless_values(owner):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_api_token_owner(_request(owner=owner))
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_gate_rejects_api_sentinel_even_without_bearer_flag():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_interactive_request(
|
||||||
|
_request(api_token=False, owner=None, scopes=(), current_user="api")
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_gate_rejects_bearer_but_preserves_cookie_and_anonymous_modes():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_interactive_request(_request())
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
assert require_interactive_request(
|
||||||
|
_request(api_token=False, owner="alice", scopes=(), current_user="alice")
|
||||||
|
) == "alice"
|
||||||
|
assert require_interactive_request(
|
||||||
|
_request(api_token=False, owner=None, scopes=(), current_user=None)
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
class _PrincipalState:
|
||||||
|
"""Inject the same request.state fields as auth middleware, without auth."""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] == "http":
|
||||||
|
headers = dict(scope.get("headers") or [])
|
||||||
|
if headers.get(b"x-api-token") == b"1":
|
||||||
|
raw_scopes = headers.get(b"x-api-scopes", b"").decode()
|
||||||
|
scopes = [item for item in raw_scopes.split(",") if item]
|
||||||
|
scope["state"] = {
|
||||||
|
"api_token": True,
|
||||||
|
"api_token_owner": headers.get(b"x-api-owner", b"").decode() or None,
|
||||||
|
"api_token_scopes": scopes,
|
||||||
|
"current_user": "api",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
scope["state"] = {
|
||||||
|
"api_token": False,
|
||||||
|
"current_user": headers.get(b"x-user", b"").decode() or None,
|
||||||
|
}
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(app):
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app),
|
||||||
|
base_url="http://forward-fix.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_chat_and_search_routes_run_scope_dependencies(monkeypatch):
|
||||||
|
from routes.chat_routes import setup_chat_routes
|
||||||
|
from routes.search.search_routes import setup_search_routes
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(setup_chat_routes(None, None, None, None, None, None))
|
||||||
|
app.include_router(setup_search_routes(None))
|
||||||
|
|
||||||
|
token_headers = {
|
||||||
|
"x-api-token": "1",
|
||||||
|
"x-api-owner": "alice",
|
||||||
|
"x-api-scopes": "email:read",
|
||||||
|
}
|
||||||
|
chat_headers = {**token_headers, "x-api-scopes": "chat"}
|
||||||
|
|
||||||
|
async with _client(_PrincipalState(app)) as client:
|
||||||
|
# Both standalone direct-search POST routes are capability-gated.
|
||||||
|
for path, body in (
|
||||||
|
("/api/search", {"query": "private"}),
|
||||||
|
("/api/search/query", {"query": "private", "provider": "brave"}),
|
||||||
|
):
|
||||||
|
response = await client.post(path, json=body, headers=token_headers)
|
||||||
|
assert response.status_code == 403, (path, response.text)
|
||||||
|
|
||||||
|
# The chat router's GET alias is gated too, and the detached-run
|
||||||
|
# controls add the stricter interactive-principal check after chat
|
||||||
|
# capability authorization succeeds.
|
||||||
|
assert (await client.get("/api/search?q=private", headers=token_headers)).status_code == 403
|
||||||
|
assert (await client.get("/api/chat/stream_status/sid", headers=chat_headers)).status_code == 403
|
||||||
|
|
||||||
|
# Cookie and auth-disabled-shaped requests retain the old no-query
|
||||||
|
# behavior rather than being rejected by bearer policy.
|
||||||
|
assert (await client.get("/api/search?q=", headers={"x-user": "alice"})).status_code == 200
|
||||||
|
assert (await client.get("/api/search?q=", headers={})).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_agent_capable_routers_reject_bearer_principals():
|
||||||
|
from routes.assistant_routes import setup_assistant_routes
|
||||||
|
from routes.research.research_routes import setup_research_routes
|
||||||
|
from routes.skills_routes import setup_skills_routes
|
||||||
|
from routes.task.task_routes import setup_task_routes
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(setup_task_routes(MagicMock()))
|
||||||
|
app.include_router(setup_skills_routes(MagicMock()))
|
||||||
|
app.include_router(setup_assistant_routes(MagicMock()))
|
||||||
|
app.include_router(setup_research_routes(SimpleNamespace(_active_tasks={})))
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-api-token": "1",
|
||||||
|
"x-api-owner": "alice",
|
||||||
|
"x-api-scopes": "chat",
|
||||||
|
}
|
||||||
|
requests = (
|
||||||
|
("/api/tasks/meta/events", "get", None),
|
||||||
|
("/api/skills/index", "get", None),
|
||||||
|
("/api/assistant/available-timezones", "get", None),
|
||||||
|
("/api/research/active", "get", None),
|
||||||
|
)
|
||||||
|
async with _client(_PrincipalState(app)) as client:
|
||||||
|
for path, method, body in requests:
|
||||||
|
response = await client.request(method.upper(), path, headers=headers)
|
||||||
|
assert response.status_code == 403, (path, response.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_auxiliary_handlers_keep_their_bearer_gates():
|
||||||
|
from routes.chat_routes import setup_chat_routes
|
||||||
|
from routes.search.search_routes import setup_search_routes
|
||||||
|
|
||||||
|
chat_router = setup_chat_routes(None, None, None, None, None, None)
|
||||||
|
search_router = setup_search_routes(None)
|
||||||
|
request = _request(scopes=("email:read",))
|
||||||
|
|
||||||
|
chat_routes = {route.path: route.endpoint for route in chat_router.routes}
|
||||||
|
search_routes = {route.path: route.endpoint for route in search_router.routes}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await chat_routes["/api/search"](request, q="private", limit=20)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await chat_routes["/api/inject_context/{session_id}"](request, "sid", "context")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await chat_routes["/api/rewrite"](request)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await search_routes["/api/search"](request)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await search_routes["/api/search/query"](request)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await chat_routes["/api/chat/resume/{session_id}"](request, "sid")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_task_skill_assistant_and_research_handlers_reject_bearer():
|
||||||
|
from routes.assistant_routes import setup_assistant_routes
|
||||||
|
from routes.research.research_routes import setup_research_routes
|
||||||
|
from routes.skills_routes import setup_skills_routes
|
||||||
|
from routes.task.task_routes import setup_task_routes
|
||||||
|
|
||||||
|
request = _request()
|
||||||
|
|
||||||
|
task_router = setup_task_routes(MagicMock())
|
||||||
|
task_create = next(route.endpoint for route in task_router.routes if route.path == "/api/tasks" and "POST" in route.methods)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await task_create(request, req=SimpleNamespace())
|
||||||
|
|
||||||
|
skills_router = setup_skills_routes(MagicMock())
|
||||||
|
skill_test = next(route.endpoint for route in skills_router.routes if route.path == "/api/skills/{skill_id}/test")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await skill_test(request, "demo")
|
||||||
|
|
||||||
|
assistant_router = setup_assistant_routes(MagicMock())
|
||||||
|
assistant_session = next(route.endpoint for route in assistant_router.routes if route.path == "/api/assistant/session")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await assistant_session(request)
|
||||||
|
|
||||||
|
research_router = setup_research_routes(SimpleNamespace(_active_tasks={}))
|
||||||
|
research_active = next(route.endpoint for route in research_router.routes if route.path == "/api/research/active")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await research_active(request)
|
||||||
|
|
||||||
|
|
||||||
|
class _MetaColumn:
|
||||||
|
def __eq__(self, _value):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def desc(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class _DbChatMessage:
|
||||||
|
session_id = _MetaColumn()
|
||||||
|
role = _MetaColumn()
|
||||||
|
timestamp = _MetaColumn()
|
||||||
|
|
||||||
|
|
||||||
|
class _MetaQuery:
|
||||||
|
def __init__(self, row):
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
def filter(self, *args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
|
||||||
|
class _MetaDb:
|
||||||
|
def __init__(self, row):
|
||||||
|
self.row = row
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
def query(self, _model):
|
||||||
|
return _MetaQuery(self.row)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonRequest:
|
||||||
|
def __init__(self, body):
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
api_token=False,
|
||||||
|
api_token_owner=None,
|
||||||
|
api_token_scopes=[],
|
||||||
|
current_user="alice",
|
||||||
|
)
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self.body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_metadata_route_normalizes_list_pairs_before_merging(monkeypatch):
|
||||||
|
import routes.history.history_routes as history_routes
|
||||||
|
|
||||||
|
session = SimpleNamespace(
|
||||||
|
history=[ChatMessage("assistant", "answer", {"keep": "yes"})],
|
||||||
|
)
|
||||||
|
db_message = SimpleNamespace(meta_data=json.dumps({"keep": "yes"}))
|
||||||
|
db = _MetaDb(db_message)
|
||||||
|
manager = SimpleNamespace(
|
||||||
|
get_session=lambda _session_id: session,
|
||||||
|
save_sessions=lambda: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(history_routes, "DbChatMessage", _DbChatMessage)
|
||||||
|
monkeypatch.setattr(history_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
router = history_routes.setup_history_routes(manager)
|
||||||
|
endpoint = next(
|
||||||
|
route.endpoint
|
||||||
|
for route in router.routes
|
||||||
|
if route.path == "/api/session/{session_id}/update-last-meta"
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"metadata": [
|
||||||
|
["tool_events", [{"ask_user": {"resolved": "approve"}}]],
|
||||||
|
[CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
response = await endpoint(_JsonRequest(body), "sid")
|
||||||
|
|
||||||
|
assert response == {"status": "ok"}
|
||||||
|
assert session.history[0].metadata == {"keep": "yes"}
|
||||||
|
assert json.loads(db_message.meta_data) == {"keep": "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_projection_discards_forged_and_malformed_metadata():
|
||||||
|
malformed = ChatMessage(
|
||||||
|
"user",
|
||||||
|
"malformed",
|
||||||
|
metadata=[[CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True]],
|
||||||
|
)
|
||||||
|
forged = ChatMessage(
|
||||||
|
"user",
|
||||||
|
"forged",
|
||||||
|
metadata={CHAT_SESSION_APPROVAL_CONTEXT_MARKER: True},
|
||||||
|
)
|
||||||
|
session = Session(
|
||||||
|
id="sid",
|
||||||
|
name="Chat",
|
||||||
|
endpoint_url="https://example.invalid/v1",
|
||||||
|
model="test",
|
||||||
|
history=[malformed, forged],
|
||||||
|
)
|
||||||
|
|
||||||
|
projected = session.get_context_messages()
|
||||||
|
|
||||||
|
assert all(
|
||||||
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (message.get("metadata") or {})
|
||||||
|
for message in projected
|
||||||
|
)
|
||||||
|
assert all(not isinstance(message.get("metadata"), list) for message in projected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_context_preprocessing_does_not_fetch_embedded_urls(monkeypatch):
|
||||||
|
import src.chat_processor as chat_processor
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
calls.append(url)
|
||||||
|
return {"success": True, "content": "must not be reached"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_processor, "fetch_webpage_content", fetch)
|
||||||
|
processor = chat_processor.ChatProcessor(
|
||||||
|
memory_manager=SimpleNamespace(load=lambda owner=None: []),
|
||||||
|
personal_docs_manager=SimpleNamespace(rag_manager=None),
|
||||||
|
skills_manager=None,
|
||||||
|
)
|
||||||
|
preface, _, _ = processor.build_context_preface(
|
||||||
|
message="Summarize https://example.test/private",
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
allow_tool_preprocessing=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert not any(
|
||||||
|
(message.get("metadata") or {}).get("source", "").startswith("web page:")
|
||||||
|
for message in preface
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extraction(monkeypatch):
|
||||||
|
from routes import chat_routes
|
||||||
|
|
||||||
|
calls = {"memory": 0, "research": 0, "post": []}
|
||||||
|
|
||||||
|
class _ChatHandler:
|
||||||
|
async def handle_memory_command(self, _session, _message):
|
||||||
|
calls["memory"] += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _ResearchHandler:
|
||||||
|
async def call_research_service(self, *args, **kwargs):
|
||||||
|
calls["research"] += 1
|
||||||
|
return "research result"
|
||||||
|
|
||||||
|
session = SimpleNamespace(
|
||||||
|
endpoint_url="https://selected.example/v1",
|
||||||
|
model="selected-model",
|
||||||
|
headers={"Authorization": "Bearer selected"},
|
||||||
|
history=[],
|
||||||
|
add_message=lambda message: session.history.append(message),
|
||||||
|
)
|
||||||
|
manager = SimpleNamespace(
|
||||||
|
get_session=lambda _session_id: session,
|
||||||
|
save_sessions=lambda: None,
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
user="alice",
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
context_length=100,
|
||||||
|
uprefs={},
|
||||||
|
preset=SimpleNamespace(temperature=0.2, max_tokens=32, character_name=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build_context(*args, **kwargs):
|
||||||
|
assert kwargs["allow_tool_preprocessing"] is False
|
||||||
|
return context
|
||||||
|
|
||||||
|
async def llm_call(*args, **kwargs):
|
||||||
|
return "answer", args[0][0], "selected-model"
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
|
||||||
|
monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False)
|
||||||
|
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "build_chat_context", build_context)
|
||||||
|
monkeypatch.setattr(chat_routes, "resolve_foreground_model_policy", lambda *args, **kwargs: SimpleNamespace(enabled=False, eligible_statuses=set()))
|
||||||
|
monkeypatch.setattr(chat_routes, "build_foreground_model_candidates", lambda *args, **kwargs: [("https://selected.example/v1", "selected-model", {})])
|
||||||
|
monkeypatch.setattr(chat_routes, "build_foreground_route_descriptors", lambda *args, **kwargs: [{"endpoint_id": None, "endpoint_label": "Selected route"}])
|
||||||
|
monkeypatch.setattr(chat_routes, "llm_call_async_with_route_fallback", llm_call)
|
||||||
|
monkeypatch.setattr(chat_routes, "apply_compaction_state", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "clean_thinking_for_save", lambda reply, metadata: (reply, metadata))
|
||||||
|
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: calls["post"].append(kwargs))
|
||||||
|
|
||||||
|
import core.database as database
|
||||||
|
|
||||||
|
monkeypatch.setattr(database, "update_session_last_accessed", lambda _session_id: None)
|
||||||
|
router = chat_routes.setup_chat_routes(
|
||||||
|
manager,
|
||||||
|
_ChatHandler(),
|
||||||
|
SimpleNamespace(),
|
||||||
|
SimpleNamespace(),
|
||||||
|
_ResearchHandler(),
|
||||||
|
SimpleNamespace(),
|
||||||
|
)
|
||||||
|
endpoint = next(route.endpoint for route in router.routes if route.path == "/api/chat")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await endpoint(
|
||||||
|
_request(),
|
||||||
|
ChatRequest(
|
||||||
|
message="research this",
|
||||||
|
session="sid",
|
||||||
|
use_research=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
result = await endpoint(
|
||||||
|
_request(),
|
||||||
|
ChatRequest(
|
||||||
|
message="remember this and research it",
|
||||||
|
session="sid",
|
||||||
|
use_research=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["response"] == "answer"
|
||||||
|
assert calls["memory"] == 0
|
||||||
|
assert calls["research"] == 0
|
||||||
|
assert calls["post"] and calls["post"][0]["allow_background_extraction"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_bearer_chat_disables_deferred_memory_extraction(monkeypatch):
|
||||||
|
from routes import chat_routes
|
||||||
|
from tests.test_foreground_model_routing import _chat_stream_endpoint
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
endpoint = _chat_stream_endpoint(
|
||||||
|
monkeypatch,
|
||||||
|
"chat",
|
||||||
|
captured,
|
||||||
|
capture_completion=True,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={},
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||||
|
state=SimpleNamespace(
|
||||||
|
api_token=True,
|
||||||
|
api_token_owner="alice",
|
||||||
|
api_token_scopes=["chat"],
|
||||||
|
current_user="api",
|
||||||
|
),
|
||||||
|
_form={"message": "hello", "session": "session-1", "mode": "chat"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def form():
|
||||||
|
return request._form
|
||||||
|
|
||||||
|
request.form = form
|
||||||
|
|
||||||
|
response = await endpoint(request)
|
||||||
|
async for _chunk in response.body_iterator:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured["post_processed"]
|
||||||
|
assert captured["post_processed"][0][1]["allow_background_extraction"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_bearer_chat_cannot_dispatch_image_generation(monkeypatch):
|
||||||
|
from routes import chat_routes
|
||||||
|
from tests.test_foreground_model_routing import _chat_stream_endpoint
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured)
|
||||||
|
monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: True)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={},
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||||
|
state=SimpleNamespace(
|
||||||
|
api_token=True,
|
||||||
|
api_token_owner="alice",
|
||||||
|
api_token_scopes=["chat"],
|
||||||
|
current_user="api",
|
||||||
|
),
|
||||||
|
_form={"message": "generate an image", "session": "session-1", "mode": "chat"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def form():
|
||||||
|
return request._form
|
||||||
|
|
||||||
|
request.form = form
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await endpoint(request)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert "image" in str(exc.value.detail).lower()
|
||||||
|
assert "chat" not in captured
|
||||||
@@ -318,12 +318,13 @@ def test_sync_chat_fallback_skips_disabled_owned_endpoint():
|
|||||||
assert ep is not None and ep.name == "shared"
|
assert ep is not None and ep.name == "shared"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_chat_fallback_null_owner_uses_shared_rows_only():
|
def test_sync_chat_fallback_rejects_missing_token_owner():
|
||||||
# When no token owner is known, only null-owner (shared) endpoints are
|
# The sync-chat route requires a resolved token owner before endpoint
|
||||||
# visible — private endpoints of any user must not be returned.
|
# selection, so even a legacy/shared endpoint is not executable for an
|
||||||
|
# ownerless bearer.
|
||||||
rows = [_ep("bob-private", "bob"), _ep("shared", None)]
|
rows = [_ep("bob-private", "bob"), _ep("shared", None)]
|
||||||
ep = _select(rows, None)
|
ep = _select(rows, None)
|
||||||
assert ep is not None and ep.name == "shared"
|
assert ep is None
|
||||||
|
|
||||||
|
|
||||||
def test_sync_chat_fallback_null_owner_returns_none_with_no_shared():
|
def test_sync_chat_fallback_null_owner_returns_none_with_no_shared():
|
||||||
|
|||||||
Reference in New Issue
Block a user