fix(security): close API token agent authorization gaps

This commit is contained in:
RaresKeY
2026-08-28 18:07:40 +00:00
parent aeff329c05
commit 9150a453b4
20 changed files with 907 additions and 114 deletions
+28 -5
View File
@@ -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
+8 -4
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+13 -1
View File
@@ -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)
+2 -8
View File
@@ -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.
+7 -4
View File
@@ -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 ""
+8 -2
View File
@@ -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()
+18
View File
@@ -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:
+8 -4
View File
@@ -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:
+11 -3
View File
@@ -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:
+6
View File
@@ -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)
+11 -11
View File
@@ -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
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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
+51 -6
View File
@@ -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"
+554
View File
@@ -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
+5 -4
View File
@@ -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():