10 Commits

Author SHA1 Message Date
Alexandre Teixeira 68f19a889a test: add fire_and_forget to API chat webhook stub 2026-06-16 03:24:48 +01:00
RaresKeY 422f23fb12 fix(mcp): scope memory server by owner (#4315) 2026-06-16 03:18:17 +01:00
TheDragonTail 0f966d6b9f fix(embeddings): fall back to default cache dir when FASTEMBED_CACHE_PATH is empty (#3434)
docker-compose.yml injects FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-},
which sets the variable to an empty string when the host has not defined it.
FASTEMBED_CACHE_DIR used os.getenv("FASTEMBED_CACHE_PATH", default), and
os.getenv only returns the default when the variable is ABSENT -- so the empty
value won and FASTEMBED_CACHE_DIR became "". os.makedirs("") then raised
[Errno 2] No such file or directory: '', FastEmbed failed to initialise, and
every vector feature (RAG, semantic memory, tool index) silently degraded on
the default Docker stack.

Treat an empty value like an absent one via `os.getenv(...) or default`.
Add a regression test covering the empty, unset, and explicit cases.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 03:11:48 +01:00
Afonso Coutinho 7b09491557 fix: check-in calendar digest leaks every user's events (missing owner scope) (#1925)
* fix: check-in calendar digest leaks every user's events (no owner scope)

* Seed dtend on calendar events in digest test so the NOT NULL column is satisfied
2026-06-16 02:42:41 +01:00
Kenny Van de Maele fafaf089c5 refactor(search): centralize the web-scraping User-Agent into one constant (#4325)
The outbound UA for web_fetch / web_search was inlined in four places with
two different values and nothing keeping them current: content.py pinned a
mid-2021 Chrome 91 build, and providers.py sent a bare Mozilla/5.0 in three
spots. Some sites serve a degraded or blocked page to a UA that old.

Add WEB_FETCH_USER_AGENT to src/constants.py (env-overridable, matching the
existing Copilot/Kimi UA-constant pattern) and import it in content.py and
providers.py. Default to a current, common desktop UA so pages return their
normal HTML: the market-leading desktop OS (Windows; NT 10.0 covers Windows
10 and 11) and browser (Chrome) on a current stable build. The version is now
bumped in one place.

Service-specific self-identifying agents (Copilot, Kimi, webhooks, cookbook)
are intentionally left separate. Adds a regression pinning the constant shape,
the env override, and a guard against a new inline Mozilla literal in the
search sources.

Closes #4324
2026-06-16 01:33:47 +00:00
RaresKeY b58af4267b fix(companion): require chat scope for model inventory (#4319) 2026-06-16 01:15:05 +02:00
AkioKoneko 8ff76f083c fix(cookbook): avoid launching Ollama during Windows cache scan (#4368) 2026-06-16 01:00:40 +02:00
Wei Hong 2196869c86 fix(webhooks): route public emitters through fire_and_forget (#3964) (#4336)
The three public webhook emitters in chat_helpers and webhook_routes
schedule deliveries via asyncio.create_task(webhook_manager.fire(...)),
which bypasses WebhookManager._bg_tasks. asyncio only holds a weak
reference to the outer task, so the GC can collect it mid-delivery and
the webhook is silently dropped.

Route all three through webhook_manager.fire_and_forget() so the task
is tracked by _spawn_tracked() and the manager owns the full lifecycle.

Adds an AST-level guard test that scans routes/ for direct
asyncio.create_task wrapping webhook_manager.fire(...) to prevent
regressions.
2026-06-16 00:41:45 +02:00
holden093 dd2e23c9af fix(agent): report phone numbers from resolve_contact when a matched contact has no email (#4327)
When a CardDAV contact matched the search query but had no email
address (only phone numbers), the tool silently dropped it and
returned 'No contacts found'.  Fall back to the contact's phone
number(s) so the caller still receives usable information.

Refs: #4178 (the contacts-domain classifier fix that made the model
actually call resolve_contact for contacts queries, surfacing this
pre-existing gap)
2026-06-16 00:03:33 +02:00
Fahim facc50cb0f fix(api): attribute bearer-token actions to the token owner on owner-scoped routes (#4054)
* fix(api): attribute bearer-token actions to the token owner on owner-scoped routes

Owner-scoped chat, session, and upload routes called
get_current_user(), which resolves a bearer ody_ API token to the
sandboxed "api" pseudo-user. A paired API-token client (companion, CLI,
IDE extension) therefore saw and created a separate "api"-owned silo
instead of the owner's data.

effective_user() already exists for exactly this: it attributes a token's
actions to request.state.api_token_owner, is identical to
get_current_user() for cookie sessions, and falls back safely when a
token has no owner. session_routes.py was already migrated; this
completes the migration for the remaining owner-scoped routes:

- chat_helpers.py: chat-privilege enforcement, message attribution, prefs/context
- chat_routes.py: orphaned-endpoint owner, session-auth owner, message search
- upload_routes.py: upload owner attribution + access checks

The /api/models swap is intentionally omitted: #4292 already migrated it
to effective_user (plus the chat-scope gate and ownerless-token 403), so
this PR keeps dev's version of routes/model_routes.py unchanged.

chat_routes.py keeps importing get_current_user for the workspace owner
gate; session_routes.py drops the now-unused import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: target effective_user in auth monkeypatches and owner-scope assertion

The owner-scoped routes now call effective_user() instead of
get_current_user(), so the tests that stubbed get_current_user (or
asserted on it) follow suit:

- test_chat_helpers.py, test_review_regressions.py,
  test_kv_cache_invalidation_2927.py: monkeypatch effective_user
- test_session_endpoint_owner_scope.py: assert the owner-scope guard uses
  effective_user(request)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:56:22 +02:00
26 changed files with 642 additions and 85 deletions
+17 -3
View File
@@ -5,8 +5,9 @@ offers and pair to it, without duplicating any LLM logic.
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
means the caller is authenticated by either a cookie session or a Bearer `ody_`
API token. The read endpoints (ping/info/models) accept either; the pairing
endpoints are admin-cookie only.
API token. Ping/info accept either credential type, models requires a chat-
scoped API token for bearer callers, and the pairing endpoints are admin-cookie
only.
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
@@ -18,7 +19,7 @@ on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
import html
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from core.middleware import require_admin
@@ -52,6 +53,18 @@ def owner_can_see(row_owner, owner) -> bool:
return row_owner is None or row_owner == owner
def require_models_scope(request: Request) -> None:
"""Require the companion chat scope for bearer-token model inventory."""
if not getattr(request.state, "api_token", False):
return
scopes = getattr(request.state, "api_token_scopes", None) or []
if isinstance(scopes, str):
scopes = [scope.strip() for scope in scopes.split(",")]
scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()}
if _pairing.COMPANION_SCOPE not in scope_set:
raise HTTPException(403, "API token requires chat scope")
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
cache, so the new token is accepted on the very next request without a server
@@ -103,6 +116,7 @@ def setup_companion_routes() -> APIRouter:
rows -- the same rule as owner_filter. Read-only; never returns api_key
material.
"""
require_models_scope(request)
import json as _json
from core.database import SessionLocal, ModelEndpoint
+90 -29
View File
@@ -6,6 +6,7 @@ Imports MemoryManager and MemoryVectorStore from the Odysseus codebase.
"""
import asyncio
import os
import sys
import time
from pathlib import Path
@@ -23,6 +24,55 @@ _memory_manager = None
_memory_vector = None
_initialized = False
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_MEMORY_OWNER", "ODYSSEUS_MEMORY_OWNER")
_OWNER_SCOPE_ERROR = (
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
)
def _configured_owner() -> str | None:
for key in _OWNER_ENV_KEYS:
owner = os.environ.get(key, "").strip()
if owner:
return owner
return None
def _entry_owner(entry: dict) -> str | None:
owner = entry.get("owner")
if owner is None:
return None
owner_text = str(owner).strip()
return owner_text or None
def _owner_scoped_store(entries: list[dict]) -> bool:
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
"""Return configured owner, all entries, visible entries, and optional error."""
entries = _memory_manager.load_all()
owner = _configured_owner()
if owner is None and _owner_scoped_store(entries):
return None, entries, [], _OWNER_SCOPE_ERROR
if owner is None:
visible = [
entry for entry in entries
if isinstance(entry, dict) and _entry_owner(entry) is None
]
else:
visible = [
entry for entry in entries
if isinstance(entry, dict) and _entry_owner(entry) == owner
]
return owner, entries, visible, None
def _text_result(text: str) -> list[TextContent]:
return [TextContent(type="text", text=text)]
def _ensure_init():
"""Lazy-init memory managers on first use."""
@@ -75,24 +125,26 @@ async def list_tools() -> list[Tool]:
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "manage_memory":
return [TextContent(type="text", text=f"Unknown tool: {name}")]
return _text_result(f"Unknown tool: {name}")
_ensure_init()
if not _memory_manager:
return [TextContent(type="text", text="Error: Memory manager not available")]
return _text_result("Error: Memory manager not available")
action = arguments.get("action", "")
if action == "list":
category_filter = arguments.get("category", "")
memories = _memory_manager.load()
_owner, _all_memories, memories, scope_error = _scope_entries()
if scope_error:
return _text_result(scope_error)
if category_filter:
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
if not memories:
msg = "No memories found"
if category_filter:
msg += f" in category '{category_filter}'"
return [TextContent(type="text", text=msg + ".")]
return _text_result(msg + ".")
lines = [f"Found {len(memories)} memory entries:\n"]
for m in memories:
@@ -102,15 +154,17 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if len(text) > 150:
text = text[:150] + "..."
lines.append(f"- [{cat}] `{mid}` — {text}")
return [TextContent(type="text", text="\n".join(lines))]
return _text_result("\n".join(lines))
elif action == "add":
text = arguments.get("text", "")
category = arguments.get("category", "fact")
if not text:
return [TextContent(type="text", text="Error: Memory text cannot be empty")]
entry = _memory_manager.add_entry(text, source="ai_agent", category=category)
memories = _memory_manager.load_all()
return _text_result("Error: Memory text cannot be empty")
owner, memories, _visible, scope_error = _scope_entries()
if scope_error:
return _text_result(scope_error)
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
memories.append(entry)
_memory_manager.save(memories)
if _memory_vector and _memory_vector.healthy:
@@ -118,25 +172,28 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
_memory_vector.add(entry["id"], text)
except Exception:
pass
return [TextContent(type="text", text=f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")]
return _text_result(f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")
elif action == "edit":
memory_id = arguments.get("memory_id", "")
new_text = arguments.get("text", "")
if not memory_id or not new_text:
return [TextContent(type="text", text="Error: edit needs memory_id and text")]
memories = _memory_manager.load_all()
found = False
return _text_result("Error: edit needs memory_id and text")
_owner, memories, visible, scope_error = _scope_entries()
if scope_error:
return _text_result(scope_error)
full_id = None
for m in memories:
for m in visible:
if m.get("id", "").startswith(memory_id):
m["text"] = new_text
m["timestamp"] = int(time.time())
found = True
full_id = m["id"]
break
if not found:
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
if not full_id:
return _text_result(f"Error: Memory '{memory_id}' not found")
for m in memories:
if m.get("id") == full_id:
m["text"] = new_text
m["timestamp"] = int(time.time())
break
_memory_manager.save(memories)
if _memory_vector and _memory_vector.healthy and full_id:
try:
@@ -144,24 +201,26 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
_memory_vector.add(full_id, new_text)
except Exception:
pass
return [TextContent(type="text", text=f"Memory updated: {new_text}")]
return _text_result(f"Memory updated: {new_text}")
elif action == "delete":
memory_id = arguments.get("memory_id", "")
if not memory_id:
return [TextContent(type="text", text="Error: delete needs memory_id")]
memories = _memory_manager.load_all()
return _text_result("Error: delete needs memory_id")
_owner, memories, visible, scope_error = _scope_entries()
if scope_error:
return _text_result(scope_error)
full_id = None
deleted_text = ""
deleted_category = ""
for m in memories:
for m in visible:
if m.get("id", "").startswith(memory_id):
full_id = m["id"]
deleted_text = m.get("text", "")
deleted_category = m.get("category", "")
break
if not full_id:
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
return _text_result(f"Error: Memory '{memory_id}' not found")
memories = [m for m in memories if m.get("id") != full_id]
_memory_manager.save(memories)
if _memory_vector and _memory_vector.healthy and full_id:
@@ -171,30 +230,32 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
pass
cat = f"[{deleted_category}] " if deleted_category else ""
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
return [TextContent(type="text", text=f"Memory deleted: {cat}{snippet} (id: {memory_id})")]
return _text_result(f"Memory deleted: {cat}{snippet} (id: {memory_id})")
elif action == "search":
query = arguments.get("text", "")
if not query:
return [TextContent(type="text", text="Error: search needs text (query)")]
memories = _memory_manager.load()
return _text_result("Error: search needs text (query)")
_owner, _all_memories, memories, scope_error = _scope_entries()
if scope_error:
return _text_result(scope_error)
if hasattr(_memory_manager, 'get_relevant_memories'):
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
else:
query_lower = query.lower()
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
if not results:
return [TextContent(type="text", text=f"No memories found matching '{query}'.")]
return _text_result(f"No memories found matching '{query}'.")
lines = [f"Found {len(results)} matching memories:\n"]
for m in results:
cat = m.get("category", "fact")
mid = m.get("id", "?")[:8]
text = m.get("text", "")
lines.append(f"- [{cat}] `{mid}` — {text}")
return [TextContent(type="text", text="\n".join(lines))]
return _text_result("\n".join(lines))
else:
return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")]
return _text_result(f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")
async def run():
+8 -8
View File
@@ -14,7 +14,7 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user
from src.prompt_security import untrusted_context_message
from routes.prefs_routes import _load_for_user as load_prefs_for_user
@@ -78,7 +78,7 @@ def _enforce_chat_privileges(request, sess) -> None:
which means unrestricted allowed_models / zero cap -> no-op for them.
"""
try:
user = get_current_user(request)
user = effective_user(request)
except Exception:
user = None
if not user:
@@ -346,11 +346,11 @@ def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, inco
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
"""Fire webhook and event_bus events for a new user message."""
if webhook_manager and not compare_mode:
asyncio.create_task(webhook_manager.fire("chat.message", {
webhook_manager.fire_and_forget("chat.message", {
"session_id": session_id, "model": sess.model, "message": message[:2000],
}))
})
from src.event_bus import fire_event
user = get_current_user(request)
user = effective_user(request)
fire_event("message_sent", user)
@@ -577,7 +577,7 @@ async def build_chat_context(
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
# Resolve user prefs
user = get_current_user(request)
user = effective_user(request)
uprefs = load_prefs_for_user(user)
# Memory enabled?
@@ -1120,10 +1120,10 @@ def run_post_response_tasks(
# Webhook
if webhook_manager and not compare_mode:
asyncio.create_task(webhook_manager.fire("chat.completed", {
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message, "response": full_response[:2000],
}))
})
# Auto-name
if needs_auto_name(sess.name):
+5 -5
View File
@@ -23,7 +23,7 @@ from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user, get_current_user
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
@@ -363,7 +363,7 @@ def setup_chat_routes(
sess = session_manager.get_session(session)
except KeyError:
raise HTTPException(404, f"Session '{session}' not found")
owner = get_current_user(request)
owner = effective_user(request)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -603,7 +603,7 @@ def setup_chat_routes(
# but BEFORE loading. Prevents cross-user session hijack.
_verify_session_owner(request, session)
sess = session_manager.get_session(session)
owner = get_current_user(request)
owner = effective_user(request)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
# Issue #587: picker shows a model from the endpoint cache but
@@ -634,7 +634,7 @@ def setup_chat_routes(
_enforce_chat_privileges(request, sess)
# Ensure session has auth headers
resolve_session_auth(sess, session, owner=get_current_user(request))
resolve_session_auth(sess, session, owner=effective_user(request))
# Check for research_pending BEFORE mode persist overwrites it
do_research = str(use_research).lower() == "true"
@@ -1485,7 +1485,7 @@ def setup_chat_routes(
if not q or not q.strip():
return []
_user = get_current_user(request)
_user = effective_user(request)
return [
result.to_dict()
for result in search_session_messages(
+3 -1
View File
@@ -505,6 +505,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" if u.startswith('KB'): return int(n * 1024)",
" return int(n)",
"def scan_ollama():",
" if any(m.get('is_ollama') for m in models): return",
" if os.name == 'nt' and not os.environ.get('ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN'): return",
" if not shutil.which('ollama'): return",
" try:",
" p = subprocess.run(['ollama', 'list'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=6)",
@@ -535,8 +537,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})",
" return",
"for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)",
"scan_ollama()",
"scan_ollama_api()",
"scan_ollama()",
]
for model_dir in model_dirs or []:
lines.append(f"scan_dir(os.path.expanduser({model_dir!r}))")
+3 -3
View File
@@ -11,7 +11,7 @@ from core.session_manager import SessionManager
from core.models import ChatMessage
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
from src.session_actions import is_session_recently_active
@@ -328,7 +328,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
endpoint_id: str = Form(""),
):
skip_val = str(skip_validation).lower() == "true"
user = get_current_user(request)
user = effective_user(request)
endpoint_api_key = ""
endpoint_base_url = ""
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
@@ -477,7 +477,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
db.close()
# Switch model/endpoint mid-session
if model is not None and endpoint_url is not None:
user = get_current_user(request)
user = effective_user(request)
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
endpoint_api_key = ""
endpoint_base_url = ""
+5 -5
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, File, UploadFile, HTTPException
from typing import List
import logging
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user
from src.upload_handler import count_recent_uploads
logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ def setup_upload_routes(upload_handler):
for u in files:
try:
meta = upload_handler.save_upload(u, client_ip, owner=get_current_user(request))
meta = upload_handler.save_upload(u, client_ip, owner=effective_user(request))
out.append({
"id": meta["id"],
"name": meta["name"],
@@ -138,7 +138,7 @@ def setup_upload_routes(upload_handler):
original_name = info.get("name", file_id)
auth_mgr = getattr(request.app.state, "auth_manager", None)
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
current_user = get_current_user(request)
current_user = effective_user(request)
file_owner = info.get("owner") if info else None
if auth_configured:
if not current_user:
@@ -204,7 +204,7 @@ def setup_upload_routes(upload_handler):
info = _load_upload_info(file_id)
auth_mgr = getattr(request.app.state, "auth_manager", None)
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
current_user = get_current_user(request)
current_user = effective_user(request)
file_owner = info.get("owner") if info else None
if auth_configured:
if not current_user:
@@ -247,7 +247,7 @@ def setup_upload_routes(upload_handler):
raise HTTPException(404, "File not found")
auth_mgr = getattr(request.app.state, "auth_manager", None)
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
current_user = get_current_user(request)
current_user = effective_user(request)
file_owner = info.get("owner")
if auth_configured:
if not current_user:
+2 -3
View File
@@ -1,6 +1,5 @@
"""Webhook, API Token, and sync chat routes."""
import asyncio
import uuid
import logging
from typing import Optional
@@ -385,10 +384,10 @@ def setup_webhook_routes(
sess.add_message(ChatMessage("assistant", reply))
session_manager.save_sessions()
asyncio.create_task(webhook_manager.fire("chat.completed", {
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message[:2000], "response": reply[:2000],
}))
})
return {"response": reply, "session_id": session_id, "model": sess.model}
+2 -2
View File
@@ -15,7 +15,7 @@ from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
from .analytics import RateLimitError, error_logger
from .cache import (
@@ -369,7 +369,7 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0,
# Fetch
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"User-Agent": WEB_FETCH_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
# identity so the streamed size cap in _get_public_url stays honest
+4 -4
View File
@@ -9,7 +9,7 @@ from urllib.parse import urljoin, urlparse, parse_qs
import httpx
from bs4 import BeautifulSoup
from src.constants import SEARXNG_INSTANCE, REQUEST_TIMEOUT
from src.constants import SEARXNG_INSTANCE, REQUEST_TIMEOUT, WEB_FETCH_USER_AGENT
from .analytics import RateLimitError, error_logger
from .query import build_enhanced_query
@@ -138,7 +138,7 @@ def searxng_search_api(query: str, count: Optional[int] = None, categories: str
count = count if count is not None else _get_result_count()
instance = _get_search_instance()
api_key = ""
headers = {"User-Agent": "Mozilla/5.0"}
headers = {"User-Agent": WEB_FETCH_USER_AGENT}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# News/fresh queries do badly in the 'general' category — it favours
@@ -250,7 +250,7 @@ def searxng_search(query, max_results=10):
"""Search using SearXNG instance - parsing HTML."""
instance = _get_search_instance()
api_key = ""
req_headers = {"User-Agent": "Mozilla/5.0"}
req_headers = {"User-Agent": WEB_FETCH_USER_AGENT}
if api_key:
req_headers["Authorization"] = f"Bearer {api_key}"
try:
@@ -389,7 +389,7 @@ def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Opti
response = httpx.get(
"https://html.duckduckgo.com/html/",
params={"q": query, "kp": _safesearch_for("duckduckgo_html")},
headers={"User-Agent": "Mozilla/5.0"},
headers={"User-Agent": WEB_FETCH_USER_AGENT},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
+14 -1
View File
@@ -57,7 +57,13 @@ MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH", os.path.join(DATA_DIR, "fastembed_cache"))
# `or` (not os.getenv's default arg) so a PRESENT-but-EMPTY value falls back to
# the default. docker-compose.yml injects `FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}`,
# which sets the var to "" when the host hasn't defined it. os.getenv(name, default)
# only returns the default when the var is ABSENT, so the empty string would win →
# os.makedirs("") raises [Errno 2] No such file or directory: '' → FastEmbed fails to
# init and all vector features (RAG, semantic memory, tool index) silently degrade.
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join(DATA_DIR, "fastembed_cache")
# Agent tool output limits (single source of truth — imported by tool_execution.py,
# tool_implementations.py, agent_tools.py, and any other module that needs them)
@@ -78,6 +84,13 @@ MAX_CONTEXT_MESSAGES = 90
REQUEST_TIMEOUT = 20
OPENAI_COMPAT_PATH = "/v1/chat/completions"
# Outbound UA for web_fetch / web_search scraping; common desktop UA so pages serve normal HTML.
WEB_FETCH_USER_AGENT = os.environ.get(
"WEB_FETCH_USER_AGENT",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
)
# Environment variables with defaults
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
+24 -5
View File
@@ -236,6 +236,29 @@ def _digest_windows(now):
]
def _checkin_calendar_events(db, owner, start, end):
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
The digest query had no owner scope, so it pulled EVERY user's events into
one user's check-in (a cross-tenant leak of summaries/locations). Scope it
by joining CalendarCal, mirroring routes/calendar_routes.list_events.
"""
from core.database import CalendarEvent as _CE, CalendarCal as _CC
return (
db.query(_CE)
.join(_CC, _CE.calendar_id == _CC.id)
.filter(
_CC.owner == owner,
_CE.dtstart >= start,
_CE.dtstart <= end,
_CE.status != "cancelled",
)
.order_by(_CE.dtstart)
.all()
)
class TaskScheduler:
def __init__(self, session_manager):
self._session_manager = session_manager
@@ -1127,11 +1150,7 @@ class TaskScheduler:
# Strip timezone for naive DB comparison
_s = start.replace(tzinfo=None) if start.tzinfo else start
_e = end.replace(tzinfo=None) if end.tzinfo else end
evs = _db.query(_CE).filter(
_CE.dtstart >= _s,
_CE.dtstart <= _e,
_CE.status != "cancelled",
).order_by(_CE.dtstart).all()
evs = _checkin_calendar_events(_db, task.owner, _s, _e)
if not evs:
continue
# Group by importance for richer output
+14 -3
View File
@@ -3797,7 +3797,7 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
if not name:
return {"error": "name is required", "exit_code": 1}
contacts = {} # email -> {name, source}
contacts = {} # email_or_phone -> {name, source, phone?}
# 1. CardDAV (Radicale) — structured contacts. Call in-process: a
# server-side httpx GET to /api/contacts/search carries no session
@@ -3812,10 +3812,18 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
match = q in hay_name or any(q in (e or "").lower() for e in c.get("emails", []))
if not match:
continue
has_email = False
for email in (c.get("emails") or []):
email = (email or "").strip().lower()
if email and "@" in email:
contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
has_email = True
# Fall back to phone numbers when the contact has no email address
if not has_email:
for phone in (c.get("phones") or []):
phone = (phone or "").strip()
if phone:
contacts[phone] = {"name": c.get("name") or phone, "source": "contacts", "phone": phone}
except Exception:
pass
@@ -3835,8 +3843,11 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
return {"output": f"No contacts found matching '{name}'.", "exit_code": 0}
lines = [f"Contacts matching '{name}':"]
for email, info in contacts.items():
lines.append(f"- {info['name']} <{email}> ({info['source']})")
for key, info in contacts.items():
if info.get("phone"):
lines.append(f"- {info['name']} — phone: {info['phone']} ({info['source']})")
else:
lines.append(f"- {info['name']} <{key}> ({info['source']})")
return {"output": "\n".join(lines), "exit_code": 0}
+1 -1
View File
@@ -1009,7 +1009,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "resolve_contact",
"description": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]' or 'email [name]' without an email address.",
"description": "Look up a contact by name. Searches CardDAV address book and sent email history. Returns email addresses (when available) or phone numbers. Use when the user says 'message [name]', 'email [name]', or asks for someone's contact details.",
"parameters": {
"type": "object",
"properties": {
+3
View File
@@ -219,6 +219,9 @@ class _WebhookManager:
async def fire(self, event, payload):
return None
def fire_and_forget(self, event, payload):
return None
def _install_sync_chat_stubs(monkeypatch):
# FastAPI checks for python_multipart at import time when Form is used;
+7 -7
View File
@@ -30,7 +30,7 @@ class _Session:
def test_allowed_models_legacy_empty_list_remains_unrestricted(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
_enforce_chat_privileges(
_Request({"allowed_models": [], "max_messages_per_day": 0}),
@@ -39,7 +39,7 @@ def test_allowed_models_legacy_empty_list_remains_unrestricted(monkeypatch):
def test_allowed_models_explicit_empty_restricted_list_blocks_all_models(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
with pytest.raises(HTTPException) as exc:
_enforce_chat_privileges(
@@ -56,7 +56,7 @@ def test_allowed_models_explicit_empty_restricted_list_blocks_all_models(monkeyp
def test_allowed_models_nonempty_list_still_restricts_without_new_flag(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
_enforce_chat_privileges(
_Request({"allowed_models": ["provider/model-a"], "max_messages_per_day": 0}),
@@ -70,7 +70,7 @@ def test_allowed_models_nonempty_list_still_restricts_without_new_flag(monkeypat
def test_no_restriction_allows_any_model(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
privs = {"allowed_models": [], "block_all_models": False, "max_messages_per_day": 0}
_enforce_chat_privileges(_Request(privs), _Session("provider/model-a"))
@@ -78,7 +78,7 @@ def test_no_restriction_allows_any_model(monkeypatch):
def test_specific_allowlist_blocks_models_outside_it(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
privs = {
"allowed_models": ["gpt-4"],
@@ -92,7 +92,7 @@ def test_specific_allowlist_blocks_models_outside_it(monkeypatch):
def test_block_all_models_blocks_regardless_of_allowed_models_contents(monkeypatch):
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "alice")
# Even if allowed_models contains entries, block_all_models wins.
privs = {
@@ -111,7 +111,7 @@ def test_block_all_models_blocks_regardless_of_allowed_models_contents(monkeypat
def test_admin_user_is_never_blocked(monkeypatch):
from core.auth import ADMIN_PRIVILEGES
monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "admin")
monkeypatch.setattr("routes.chat_helpers.effective_user", lambda request: "admin")
class _AdminAuthManager:
def get_privileges(self, username):
+70
View File
@@ -0,0 +1,70 @@
"""Check-in calendar digest must be scoped to the task owner.
The digest query selected CalendarEvent with no owner scope, so a scheduled
check-in for one user pulled EVERY user's calendar events (summaries,
locations) into their digest a cross-tenant leak. Ownership lives on
CalendarCal.owner; the query must join it, like routes/calendar_routes.
"""
import tempfile
import uuid
from datetime import datetime
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import CalendarEvent, CalendarCal
from src.task_scheduler import _checkin_calendar_events
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(f"sqlite:///{_TMPDB.name}", connect_args={"check_same_thread": False}, poolclass=NullPool)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
def _seed():
db = _TS()
try:
db.query(CalendarEvent).delete(); db.query(CalendarCal).delete()
db.add(CalendarCal(id="calA", owner="alice", name="A"))
db.add(CalendarCal(id="calB", owner="bob", name="B"))
db.add(CalendarEvent(uid="a1", calendar_id="calA", summary="Alice mtg",
dtstart=datetime(2026, 6, 10, 9, 0),
dtend=datetime(2026, 6, 10, 10, 0), status="confirmed"))
db.add(CalendarEvent(uid="b1", calendar_id="calB", summary="Bob secret",
dtstart=datetime(2026, 6, 10, 10, 0),
dtend=datetime(2026, 6, 10, 11, 0), status="confirmed"))
db.commit()
finally:
db.close()
def test_digest_only_returns_owner_events():
_seed()
db = _TS()
try:
s, e = datetime(2026, 6, 1), datetime(2026, 6, 30)
alice = _checkin_calendar_events(db, "alice", s, e)
assert [ev.summary for ev in alice] == ["Alice mtg"] # not Bob's
bob = _checkin_calendar_events(db, "bob", s, e)
assert [ev.summary for ev in bob] == ["Bob secret"]
finally:
db.close()
def test_cancelled_excluded_and_window_respected():
_seed()
db = _TS()
try:
db2 = _TS()
db2.add(CalendarEvent(uid="a2", calendar_id="calA", summary="cancelled",
dtstart=datetime(2026, 6, 11),
dtend=datetime(2026, 6, 11, 1, 0), status="cancelled"))
db2.commit(); db2.close()
s, e = datetime(2026, 6, 1), datetime(2026, 6, 30)
out = _checkin_calendar_events(db, "alice", s, e)
assert "cancelled" not in [ev.summary for ev in out]
finally:
db.close()
+32 -2
View File
@@ -13,6 +13,9 @@ import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# core.database instantiates SQLAlchemy declarative classes at import time, which
@@ -225,12 +228,34 @@ def test_models_route_scopes_api_token_to_token_owner(monkeypatch):
endpoints = _call_models_route(
monkeypatch,
rows,
_request(api_token=True, api_token_owner="alice", current_user="api"),
_request(
api_token=True,
api_token_owner="alice",
api_token_scopes=["chat"],
current_user="api",
),
)
assert _endpoint_names(endpoints) == ["alice-endpoint", "shared-endpoint"]
def test_models_route_rejects_api_token_without_chat_scope(monkeypatch):
monkeypatch.setattr(companion_routes, "get_current_user", lambda request: "api")
with pytest.raises(HTTPException) as exc:
_models_route()(
_request(
api_token=True,
api_token_owner="alice",
api_token_scopes=["todos:read"],
current_user="api",
)
)
assert exc.value.status_code == 403
assert "chat scope" in exc.value.detail
def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
rows = [
_ep(1, "alice-endpoint", "alice"),
@@ -242,7 +267,12 @@ def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
endpoints = _call_models_route(
monkeypatch,
rows,
_request(api_token=True, api_token_owner=None, current_user="api"),
_request(
api_token=True,
api_token_owner=None,
api_token_scopes=["chat"],
current_user="api",
),
)
assert _endpoint_names(endpoints) == ["shared-endpoint"]
+44
View File
@@ -786,6 +786,50 @@ def test_cached_model_scan_reports_plain_dir_gguf(tmp_path):
assert ggufs[3]["quant"] == "BF16"
def test_cached_model_scan_uses_ollama_api_before_cli_and_windows_opt_in():
script = _cached_model_scan_script()
assert "scan_ollama_api()\nscan_ollama()" in script
assert "if any(m.get('is_ollama') for m in models): return" in script
assert "os.name == 'nt'" in script
assert "ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN" in script
@pytest.mark.skipif(os.name != "nt", reason="Windows Ollama CLI startup guard")
def test_cached_model_scan_does_not_launch_ollama_cli_on_windows(tmp_path):
"""Official Ollama for Windows can auto-start the tray/server on `ollama list`.
The read-only cache scanner must not invoke that CLI unless explicitly opted in.
"""
marker = tmp_path / "ollama-called.txt"
fake_ollama = tmp_path / "ollama.cmd"
fake_ollama.write_text(
"@echo off\r\n"
f'echo called>"{marker}"\r\n'
"echo NAME ID SIZE MODIFIED\r\n"
"echo local-model:latest abc 1 GB now\r\n",
encoding="utf-8",
)
empty_home = tmp_path / "home"
empty_home.mkdir()
scan_py = tmp_path / "scan_cache.py"
scan_py.write_text(_cached_model_scan_script(), encoding="utf-8")
env = dict(os.environ)
env["PATH"] = str(tmp_path) + os.pathsep + env.get("PATH", "")
env["HOME"] = str(empty_home)
env.pop("ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN", None)
proc = subprocess.run(
[sys.executable, str(scan_py)],
check=True,
capture_output=True,
text=True,
env=env,
)
assert marker.exists() is False
assert all(m.get("backend") != "ollama" for m in json.loads(proc.stdout))
def test_cached_model_scan_uses_huggingface_cache_env(tmp_path):
"""Docker recreates can leave the persisted HF cache outside HOME.
The Serve scanner should honor the cache env path instead of only ~/.cache.
+69
View File
@@ -0,0 +1,69 @@
"""Regression: FASTEMBED_CACHE_DIR must tolerate a PRESENT-but-EMPTY
FASTEMBED_CACHE_PATH.
docker-compose.yml injects ``FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}``,
which sets the variable to ``""`` when the host has not defined it. The old
``os.getenv("FASTEMBED_CACHE_PATH", default)`` only used the default when the
variable was ABSENT, so an empty value made ``FASTEMBED_CACHE_DIR == ""``
``os.makedirs("")`` raised ``[Errno 2] No such file or directory: ''``
FastEmbed failed to initialise and every vector feature (RAG, semantic memory,
tool index) silently degraded on the default Docker stack.
These tests pin the fix: empty is treated like absent use the DATA_DIR
default, while an explicit non-empty override is still honoured.
"""
from __future__ import annotations
import importlib
import os
import src.constants as constants
def _reload_with(monkeypatch, value):
"""Reload src.constants with FASTEMBED_CACHE_PATH set to ``value`` (or
removed when ``value`` is None) and return the reloaded module."""
if value is None:
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
else:
monkeypatch.setenv("FASTEMBED_CACHE_PATH", value)
return importlib.reload(constants)
def _restore(monkeypatch):
"""Return the module to its env-default state so reloading it here does
not leak a test-specific FASTEMBED_CACHE_DIR into other tests."""
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
importlib.reload(constants)
def test_empty_fastembed_cache_path_falls_back_to_default(monkeypatch):
"""The bug: an empty FASTEMBED_CACHE_PATH (exactly what Docker injects)
must fall back to the DATA_DIR default, never the empty string."""
try:
mod = _reload_with(monkeypatch, "")
assert mod.FASTEMBED_CACHE_DIR, "empty env must not yield an empty path"
assert mod.FASTEMBED_CACHE_DIR == os.path.join(mod.DATA_DIR, "fastembed_cache")
finally:
_restore(monkeypatch)
def test_unset_fastembed_cache_path_uses_default(monkeypatch):
"""Sanity: an absent variable also resolves to the default."""
try:
mod = _reload_with(monkeypatch, None)
assert mod.FASTEMBED_CACHE_DIR == os.path.join(mod.DATA_DIR, "fastembed_cache")
finally:
_restore(monkeypatch)
def test_explicit_fastembed_cache_path_is_respected(monkeypatch):
"""A real explicit override must still win — the fix only changes the
empty-value handling, not the documented FASTEMBED_CACHE_PATH override."""
custom = os.path.join("custom", "fastembed-cache")
try:
mod = _reload_with(monkeypatch, custom)
assert mod.FASTEMBED_CACHE_DIR == custom
finally:
_restore(monkeypatch)
+1 -1
View File
@@ -79,7 +79,7 @@ def _build_context_harness(monkeypatch, chat_helpers, history):
monkeypatch.setattr(chat_helpers, "extract_preset", fake_extract_preset)
monkeypatch.setattr(chat_helpers, "add_user_message", fake_add_user_message)
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
monkeypatch.setattr(chat_helpers, "get_current_user", lambda request: "tester")
monkeypatch.setattr(chat_helpers, "effective_user", lambda request: "tester")
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
+150
View File
@@ -0,0 +1,150 @@
import asyncio
import mcp_servers.memory_server as memory_server
from src.memory import MemoryManager
class FakeVector:
healthy = True
def __init__(self):
self.added = []
self.removed = []
def add(self, memory_id, text):
self.added.append((memory_id, text))
def remove(self, memory_id):
self.removed.append(memory_id)
def _tool_text(arguments):
result = asyncio.run(memory_server.call_tool("manage_memory", arguments))
return result[0].text
def _entry(manager, text, owner=None, memory_id=None, category="fact"):
entry = manager.add_entry(text, owner=owner, category=category)
if memory_id:
entry["id"] = memory_id
return entry
def _configure_server(monkeypatch, manager, vector=None):
monkeypatch.setattr(memory_server, "_memory_manager", manager)
monkeypatch.setattr(memory_server, "_memory_vector", vector)
monkeypatch.setattr(memory_server, "_initialized", True)
for key in memory_server._OWNER_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def test_mcp_memory_uses_configured_owner_for_all_operations(monkeypatch, tmp_path):
manager = MemoryManager(str(tmp_path))
vector = FakeVector()
alice = _entry(
manager,
"Alice likes green tea",
owner="alice",
memory_id="aaaaaaaa-0000-0000-0000-000000000000",
)
bob = _entry(
manager,
"Bob likes espresso",
owner="bob",
memory_id="bbbbbbbb-0000-0000-0000-000000000000",
)
manager.save([alice, bob])
_configure_server(monkeypatch, manager, vector)
monkeypatch.setenv("ODYSSEUS_MCP_MEMORY_OWNER", "alice")
list_text = _tool_text({"action": "list"})
assert "Alice likes green tea" in list_text
assert "Bob likes espresso" not in list_text
search_text = _tool_text({"action": "search", "text": "likes"})
assert "Alice likes green tea" in search_text
assert "Bob likes espresso" not in search_text
add_text = _tool_text({
"action": "add",
"text": "Alice prefers concise notes",
"category": "preference",
})
assert "Memory added" in add_text
added = next(
entry for entry in manager.load_all()
if entry["text"] == "Alice prefers concise notes"
)
assert added["owner"] == "alice"
assert vector.added == [(added["id"], "Alice prefers concise notes")]
edit_text = _tool_text({
"action": "edit",
"memory_id": bob["id"][:8],
"text": "Bob changed",
})
assert edit_text == "Error: Memory 'bbbbbbbb' not found"
bob_after_edit = next(
entry for entry in manager.load_all()
if entry["id"] == bob["id"]
)
assert bob_after_edit["text"] == "Bob likes espresso"
delete_text = _tool_text({"action": "delete", "memory_id": bob["id"][:8]})
assert delete_text == "Error: Memory 'bbbbbbbb' not found"
assert any(entry["id"] == bob["id"] for entry in manager.load_all())
def test_mcp_memory_fails_closed_without_owner_for_owner_scoped_store(monkeypatch, tmp_path):
manager = MemoryManager(str(tmp_path))
alice = _entry(manager, "Alice private memory", owner="alice", memory_id="aaaaaaaa-0000")
bob = _entry(manager, "Bob private memory", owner="bob", memory_id="bbbbbbbb-0000")
manager.save([alice, bob])
_configure_server(monkeypatch, manager, FakeVector())
before = manager.load_all()
actions = [
{"action": "list"},
{"action": "search", "text": "private"},
{"action": "add", "text": "new ownerless memory"},
{"action": "edit", "memory_id": alice["id"][:8], "text": "changed"},
{"action": "delete", "memory_id": alice["id"][:8]},
]
for arguments in actions:
assert _tool_text(arguments).startswith("Error: Memory MCP owner is not configured")
assert manager.load_all() == before
def test_mcp_memory_preserves_ownerless_local_behavior(monkeypatch, tmp_path):
manager = MemoryManager(str(tmp_path))
legacy = _entry(
manager,
"Legacy local memory",
memory_id="llllllll-0000-0000-0000-000000000000",
)
manager.save([legacy])
_configure_server(monkeypatch, manager, FakeVector())
assert "Legacy local memory" in _tool_text({"action": "list"})
assert "Legacy local memory" in _tool_text({"action": "search", "text": "legacy"})
add_text = _tool_text({"action": "add", "text": "Another local memory"})
assert "Memory added" in add_text
added = next(
entry for entry in manager.load_all()
if entry["text"] == "Another local memory"
)
assert "owner" not in added
assert _tool_text({
"action": "edit",
"memory_id": legacy["id"][:8],
"text": "Updated local memory",
}) == "Memory updated: Updated local memory"
assert any(entry["text"] == "Updated local memory" for entry in manager.load_all())
delete_text = _tool_text({"action": "delete", "memory_id": legacy["id"][:8]})
assert delete_text.startswith("Memory deleted:")
assert all(entry["id"] != legacy["id"] for entry in manager.load_all())
+1 -1
View File
@@ -385,7 +385,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
monkeypatch.setattr(chat_helpers, "extract_preset", fake_extract_preset)
monkeypatch.setattr(chat_helpers, "add_user_message", fake_add_user_message)
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
monkeypatch.setattr(chat_helpers, "get_current_user", lambda request: "tester")
monkeypatch.setattr(chat_helpers, "effective_user", lambda request: "tester")
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
+1 -1
View File
@@ -52,6 +52,6 @@ def test_chat_endpoint_recovery_paths_are_owner_scoped():
assert "def _clear_orphaned_session_endpoint(sess, owner:" in chat_routes
assert "def _recover_empty_session_model(sess, session_id: str, owner:" in chat_routes
assert "q = owner_filter(q, ModelEndpoint, owner)" in chat_routes
assert "resolve_session_auth(sess, session, owner=get_current_user(request))" in chat_routes
assert "resolve_session_auth(sess, session, owner=effective_user(request))" in chat_routes
assert "def resolve_session_auth(sess, session_id: str, owner:" in chat_helpers
assert "update_q = update_q.filter(DBSession.owner == owner)" in chat_helpers
+18
View File
@@ -0,0 +1,18 @@
"""The web scraping path routes its User-Agent through one constant.
Guards the dedup: web_fetch / web_search outbound UAs go through
WEB_FETCH_USER_AGENT, so a stale or bare Mozilla string cannot be re-inlined in
the search sources.
"""
from pathlib import Path
_SEARCH = Path(__file__).resolve().parent.parent / "services" / "search"
def test_search_sources_have_no_inline_mozilla_ua():
offenders = [
str(py.relative_to(_SEARCH.parent.parent))
for py in _SEARCH.rglob("*.py")
if "Mozilla/" in py.read_text(encoding="utf-8")
]
assert not offenders, f"inline Mozilla UA found; use WEB_FETCH_USER_AGENT: {offenders}"
@@ -0,0 +1,54 @@
"""Guard: every public webhook emitter goes through the manager.
Public emitters in `routes/` must schedule their fire through
`webhook_manager.fire_and_forget(...)` (or `_spawn_tracked`). A bare
`asyncio.create_task(webhook_manager.fire(...))` escapes
`WebhookManager._bg_tasks`, so asyncio only holds a weak reference to the
delivery task and the GC can collect it before it sends silently dropping
the webhook. Catching this with a scan stops a regression from sneaking
back in via a copy-paste.
"""
import ast
from pathlib import Path
ROUTES_DIR = Path(__file__).resolve().parent.parent / "routes"
def _untracked_fire_calls(tree: ast.AST) -> list[tuple[int, str]]:
"""Return (lineno, snippet) for any asyncio.create_task(webhook_manager.fire(...))."""
hits: list[tuple[int, str]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not (isinstance(func, ast.Attribute) and func.attr == "create_task"):
continue
if not (isinstance(func.value, ast.Name) and func.value.id == "asyncio"):
continue
if not node.args:
continue
inner = node.args[0]
if not isinstance(inner, ast.Call):
continue
inner_func = inner.func
if (
isinstance(inner_func, ast.Attribute)
and inner_func.attr == "fire"
and isinstance(inner_func.value, ast.Name)
and inner_func.value.id == "webhook_manager"
):
hits.append((node.lineno, ast.unparse(node)))
return hits
def test_no_untracked_webhook_fire_in_routes():
offenders: list[str] = []
for path in ROUTES_DIR.rglob("*.py"):
tree = ast.parse(path.read_text(), filename=str(path))
for lineno, snippet in _untracked_fire_calls(tree):
offenders.append(f"{path.relative_to(ROUTES_DIR.parent)}:{lineno}: {snippet}")
assert not offenders, (
"Public webhook emitters must use webhook_manager.fire_and_forget(...) "
"so the delivery task is tracked in WebhookManager._bg_tasks. Found "
"untracked emitter(s):\n " + "\n ".join(offenders)
)