mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 06:42:20 +02:00
Compare commits
17
Commits
70a71f603c
...
7b9ef95b60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9ef95b60 | ||
|
|
a8d9a180d9 | ||
|
|
d42e6a7acc | ||
|
|
5dd5847d4b | ||
|
|
7d10fb6260 | ||
|
|
b70ae56ffa | ||
|
|
491a8a5480 | ||
|
|
26483661da | ||
|
|
7268c49992 | ||
|
|
cd6041477c | ||
|
|
a96593a99b | ||
|
|
7a830e504d | ||
|
|
63d93ff211 | ||
|
|
5da662441c | ||
|
|
d885c70462 | ||
|
|
3ef88fc7ff | ||
|
|
e03491664a |
@@ -40,6 +40,22 @@ DEFAULT_AUTH_PATH = os.path.join(
|
||||
)
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||
# sentinels; they must never belong to a real account. The most dangerous is
|
||||
# "internal-tool": `core.middleware.require_admin` treats any request whose
|
||||
# `current_user == "internal-tool"` as the in-process tool loopback and grants
|
||||
# admin, and because the cookie auth path sets `current_user` to the raw
|
||||
# username, an account literally named "internal-tool" would be silently
|
||||
# treated as an admin by every `require_admin`-gated route. "api" collides with
|
||||
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
|
||||
# synthetic-owner set the rest of the codebase already special-cases (see
|
||||
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
|
||||
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
|
||||
# of those names would be denied an assistant and inconsistently owner-scoped.
|
||||
# Refuse to create or rename into any of them so the sentinels can't be
|
||||
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
||||
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
@@ -73,6 +89,15 @@ class AuthManager:
|
||||
if os.path.exists(self.auth_path):
|
||||
with open(self.auth_path, "r", encoding="utf-8") as f:
|
||||
self._config = json.load(f)
|
||||
# Normalize all stored usernames to lowercase so they match
|
||||
# the .strip().lower() applied at login/verify time. Fixes
|
||||
# "Invalid credentials" when auth.json was written with
|
||||
# mixed-case keys (e.g. via manual edit or a future migration).
|
||||
if "users" in self._config:
|
||||
self._config["users"] = {
|
||||
k.strip().lower(): v
|
||||
for k, v in self._config["users"].items()
|
||||
}
|
||||
logger.info("Auth config loaded")
|
||||
else:
|
||||
self._config = {}
|
||||
@@ -168,6 +193,11 @@ class AuthManager:
|
||||
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
|
||||
"""Create a new user account."""
|
||||
username = username.strip().lower()
|
||||
if not username:
|
||||
return False
|
||||
if username in RESERVED_USERNAMES:
|
||||
logger.warning("Refused to create reserved username '%s'", username)
|
||||
return False
|
||||
if username in self.users:
|
||||
return False
|
||||
if "users" not in self._config:
|
||||
@@ -221,6 +251,9 @@ class AuthManager:
|
||||
requesting_user = (requesting_user or "").strip().lower()
|
||||
if not old_username or not new_username:
|
||||
return False
|
||||
if new_username in RESERVED_USERNAMES:
|
||||
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
|
||||
return False
|
||||
if old_username not in self.users:
|
||||
return False
|
||||
if new_username in self.users:
|
||||
@@ -446,6 +479,22 @@ class AuthManager:
|
||||
self._sessions.pop(token, None)
|
||||
self._save_sessions()
|
||||
|
||||
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
|
||||
"""Revoke active browser sessions for a user, optionally preserving one."""
|
||||
username = username.strip().lower()
|
||||
revoked = 0
|
||||
with self._sessions_lock:
|
||||
to_drop = [
|
||||
token for token, session in self._sessions.items()
|
||||
if token != except_token and (session or {}).get("username") == username
|
||||
]
|
||||
for token in to_drop:
|
||||
self._sessions.pop(token, None)
|
||||
revoked += 1
|
||||
if revoked:
|
||||
self._save_sessions()
|
||||
return revoked
|
||||
|
||||
def status(self, token: Optional[str]) -> Dict[str, Any]:
|
||||
username = self.get_username_for_token(token)
|
||||
authenticated = username is not None
|
||||
|
||||
@@ -178,9 +178,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(400, "Password must be at least 8 characters")
|
||||
current_token = request.cookies.get(SESSION_COOKIE)
|
||||
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
||||
if not ok:
|
||||
raise HTTPException(400, "Current password is incorrect")
|
||||
await asyncio.to_thread(auth_manager.revoke_user_sessions, user, current_token)
|
||||
return {"ok": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+28
-2
@@ -424,6 +424,31 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
return {"reachable": False, "status_code": None, "error": last_error}
|
||||
|
||||
|
||||
|
||||
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
||||
"""Return a provider-aware error message for failed endpoint probes."""
|
||||
ping = ping or {}
|
||||
error = ping.get("error")
|
||||
parsed = urlparse(base_url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
|
||||
|
||||
if is_ollama:
|
||||
parts = ["No Ollama models found for that endpoint."]
|
||||
if error:
|
||||
parts.append(f"Last probe error: {error}.")
|
||||
parts.append("Check that Ollama is running and that the base URL is correct.")
|
||||
parts.append("For native/local installs, use http://localhost:11434/v1.")
|
||||
parts.append("For Docker, use http://host.docker.internal:11434/v1 when Ollama runs on the host.")
|
||||
parts.append("Run `ollama list` to confirm at least one model is installed.")
|
||||
return " ".join(parts)
|
||||
|
||||
if error:
|
||||
return f"No models found for that provider/key. Last probe error: {error}."
|
||||
|
||||
return "No models found for that provider/key."
|
||||
|
||||
|
||||
def setup_model_routes(model_discovery):
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -865,8 +890,9 @@ def setup_model_routes(model_discovery):
|
||||
_PROVIDERS_CACHE_TTL = 30 # seconds
|
||||
|
||||
@router.get("/providers")
|
||||
def providers(refresh: bool = False):
|
||||
def providers(request: Request, refresh: bool = False):
|
||||
"""Get all available providers (cached for 30s)."""
|
||||
require_admin(request)
|
||||
now = _time.time()
|
||||
if not refresh and _providers_cache["data"] is not None and (now - _providers_cache["time"]) < _PROVIDERS_CACHE_TTL:
|
||||
return _providers_cache["data"]
|
||||
@@ -999,7 +1025,7 @@ def setup_model_routes(model_discovery):
|
||||
if should_probe and not model_ids:
|
||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
||||
if require_model_list and not model_ids:
|
||||
raise HTTPException(400, "No models found for that provider/key")
|
||||
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
||||
|
||||
ep_id = str(uuid.uuid4())[:8]
|
||||
db = SessionLocal()
|
||||
|
||||
+43
-14
@@ -24,9 +24,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
||||
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
||||
quiet_logs()
|
||||
|
||||
import argparse, json, logging, os, sqlite3, subprocess, sys, tarfile, tempfile
|
||||
import argparse, json, logging, os, shutil, sqlite3, subprocess, sys, tarfile, tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
_DATA_DIR = _REPO_ROOT / "data"
|
||||
_BACKUP_DIR = _REPO_ROOT / "backups"
|
||||
@@ -70,7 +70,7 @@ def cmd_snapshot(args):
|
||||
)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file()]
|
||||
sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file() and not p.is_symlink()]
|
||||
files_added = 0
|
||||
total_bytes = 0
|
||||
|
||||
@@ -87,7 +87,7 @@ def cmd_snapshot(args):
|
||||
|
||||
with tarfile.open(out_path, "w:gz") as tar:
|
||||
for p in sorted(_DATA_DIR.rglob("*")):
|
||||
if not p.is_file():
|
||||
if not p.is_file() or p.is_symlink():
|
||||
continue
|
||||
rel = p.relative_to(_DATA_DIR.parent)
|
||||
# Skip user-asked-to-skip categories
|
||||
@@ -143,6 +143,7 @@ def cmd_verify(args):
|
||||
try:
|
||||
with tarfile.open(path, "r:gz") as tar:
|
||||
members = tar.getmembers()
|
||||
_validate_restore_members(members)
|
||||
except (tarfile.TarError, OSError) as e:
|
||||
fail(f"tarball is corrupt: {e}")
|
||||
emit({
|
||||
@@ -154,6 +155,35 @@ def cmd_verify(args):
|
||||
}, args)
|
||||
|
||||
|
||||
def _validate_restore_members(members):
|
||||
"""Reject archive entries that can escape data/ during restore."""
|
||||
for m in members:
|
||||
rel = PurePosixPath(m.name)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
fail(f"refusing tarball with absolute/parent path: {m.name!r}")
|
||||
if not rel.parts or rel.parts[0] != "data":
|
||||
fail(f"refusing tarball with entry outside data/: {m.name!r}")
|
||||
if m.issym() or m.islnk():
|
||||
fail(f"refusing tarball with link entry: {m.name!r}")
|
||||
if not (m.isdir() or m.isfile()):
|
||||
fail(f"refusing tarball with special file entry: {m.name!r}")
|
||||
|
||||
|
||||
def _extract_restore_members(tar, members, root: Path) -> None:
|
||||
"""Extract only regular files/directories after validation."""
|
||||
for m in members:
|
||||
target = root.joinpath(*PurePosixPath(m.name).parts)
|
||||
if m.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
src = tar.extractfile(m)
|
||||
if src is None:
|
||||
fail(f"extract failed: could not read {m.name!r}")
|
||||
with src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
def cmd_restore(args):
|
||||
"""Overwrite `data/` from a tarball. Destructive; requires --yes."""
|
||||
path = Path(args.path)
|
||||
@@ -161,26 +191,25 @@ def cmd_restore(args):
|
||||
fail(f"no file at {path}")
|
||||
if not args.yes:
|
||||
fail("restore is destructive — pass --yes to confirm overwriting data/")
|
||||
# Sanity check: tarball entries must all be under `data/`. If anyone
|
||||
# crafted a malicious tarball with `../etc/passwd`, refuse.
|
||||
# Sanity check: tarball entries must all be safe, regular files/dirs under
|
||||
# `data/`. Avoid extractall() so symlink/hardlink entries can't redirect a
|
||||
# later write outside the repo.
|
||||
stash = None
|
||||
with tarfile.open(path, "r:gz") as tar:
|
||||
for m in tar.getmembers():
|
||||
if m.name.startswith("/") or ".." in Path(m.name).parts:
|
||||
fail(f"refusing tarball with absolute/parent path: {m.name!r}")
|
||||
if not m.name.startswith("data/") and m.name != "data":
|
||||
fail(f"refusing tarball with entry outside data/: {m.name!r}")
|
||||
members = tar.getmembers()
|
||||
_validate_restore_members(members)
|
||||
# Save a safety copy of current data/ before extracting.
|
||||
if _DATA_DIR.exists():
|
||||
if _DATA_DIR.exists() or _DATA_DIR.is_symlink():
|
||||
stash = _REPO_ROOT / f"data.before-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
os.rename(_DATA_DIR, stash)
|
||||
try:
|
||||
tar.extractall(path=_REPO_ROOT)
|
||||
_extract_restore_members(tar, members, _REPO_ROOT)
|
||||
except Exception as e:
|
||||
fail(f"extract failed: {e}")
|
||||
emit({
|
||||
"ok": True,
|
||||
"restored_from": str(path),
|
||||
"previous_data_stashed_at": str(stash) if _DATA_DIR.exists() else None,
|
||||
"previous_data_stashed_at": str(stash) if stash else None,
|
||||
}, args)
|
||||
|
||||
|
||||
|
||||
@@ -363,19 +363,33 @@ class SkillsManager:
|
||||
|
||||
return sk.to_dict()
|
||||
|
||||
def update_skill(self, skill_id: str, updates: Dict) -> bool:
|
||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk)."""
|
||||
renames if `name` changes (file is moved on disk).
|
||||
|
||||
The call is owner-scoped: it matches a skill on disk only if
|
||||
`skill.owner == owner` (string compare; both empty-string and
|
||||
None mean "ownerless"). When `owner is None` (the default), the
|
||||
call only matches skills whose own `owner` field is empty —
|
||||
callers that want to edit an owned skill must pass the matching
|
||||
owner explicitly. This prevents a caller with one owner from
|
||||
mutating a file owned by another user that happens to share
|
||||
the same slug across category directories. The `owner` key in
|
||||
`updates` is also ignored — ownership is not an editable field
|
||||
via this path; rename or admin tooling is required for that.
|
||||
"""
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if not sk or sk.name != skill_id:
|
||||
continue
|
||||
if (sk.owner or "") != (owner or ""):
|
||||
continue
|
||||
|
||||
old_dir = os.path.dirname(path)
|
||||
|
||||
# Apply updates in a Skill-shape friendly way
|
||||
scalar_keys = (
|
||||
"description", "version", "category", "status", "confidence",
|
||||
"source", "teacher_model", "owner", "when_to_use",
|
||||
"source", "teacher_model", "when_to_use",
|
||||
"body_extra",
|
||||
)
|
||||
for k in scalar_keys:
|
||||
@@ -421,11 +435,13 @@ class SkillsManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_skill(self, skill_id: str) -> bool:
|
||||
def delete_skill(self, skill_id: str, owner: Optional[str] = None) -> bool:
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if not sk or sk.name != skill_id:
|
||||
continue
|
||||
if (sk.owner or "") != (owner or ""):
|
||||
continue
|
||||
skill_dir = os.path.dirname(path)
|
||||
try:
|
||||
# Remove the whole skill dir
|
||||
|
||||
+1
-1
@@ -1358,7 +1358,7 @@ async def stream_agent_loop(
|
||||
except Exception as _e:
|
||||
logger.debug(f"endpoint supports_tools lookup failed: {_e}")
|
||||
_model_supports_tools = any(kw in _model_lc for kw in (
|
||||
"deepseek", "gpt-4", "gpt-5", "gpt-o", "claude", "gemini",
|
||||
"deepseek", "gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma",
|
||||
"qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2",
|
||||
"llama-3.3", "llama-4",
|
||||
# Local-served models that follow OpenAI-style function calling
|
||||
|
||||
+29
-16
@@ -5,6 +5,7 @@ import time
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import threading
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, Dict, List
|
||||
from urllib.parse import urlparse
|
||||
@@ -56,6 +57,12 @@ DEAD_HOST_COOLDOWN = 20.0
|
||||
_HOST_FAIL_THRESHOLD = 2
|
||||
_dead_hosts: Dict[str, float] = {}
|
||||
_host_fails: Dict[str, int] = {}
|
||||
# Guards the two maps above. The synchronous llm_call() runs inside FastAPI's
|
||||
# threadpool (sync routes such as /sessions/auto-sort) while llm_call_async()
|
||||
# runs on the event loop, so these maps are mutated from multiple OS threads.
|
||||
# Without the lock the get()+1+set on _host_fails is a read-modify-write that
|
||||
# loses failure counts under concurrent connect errors (issue #659).
|
||||
_host_health_lock = threading.Lock()
|
||||
_model_activity: Dict[str, float] = {}
|
||||
|
||||
def _model_activity_key(url: str, model: str) -> str:
|
||||
@@ -81,13 +88,14 @@ def _host_key(url: str) -> str:
|
||||
|
||||
def _is_host_dead(url: str) -> bool:
|
||||
key = _host_key(url)
|
||||
exp = _dead_hosts.get(key)
|
||||
if exp is None:
|
||||
return False
|
||||
if time.time() >= exp:
|
||||
_dead_hosts.pop(key, None)
|
||||
return False
|
||||
return True
|
||||
with _host_health_lock:
|
||||
exp = _dead_hosts.get(key)
|
||||
if exp is None:
|
||||
return False
|
||||
if time.time() >= exp:
|
||||
_dead_hosts.pop(key, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _mark_host_dead(url: str) -> bool:
|
||||
"""Record a connect failure. Only actually cools the host after
|
||||
@@ -95,17 +103,19 @@ def _mark_host_dead(url: str) -> bool:
|
||||
is now cooled (so callers can log accurately), False if it's still
|
||||
within its allowed-failure grace."""
|
||||
key = _host_key(url)
|
||||
n = _host_fails.get(key, 0) + 1
|
||||
_host_fails[key] = n
|
||||
if n >= _HOST_FAIL_THRESHOLD:
|
||||
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
|
||||
return True
|
||||
return False
|
||||
with _host_health_lock:
|
||||
n = _host_fails.get(key, 0) + 1
|
||||
_host_fails[key] = n
|
||||
if n >= _HOST_FAIL_THRESHOLD:
|
||||
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clear_host_dead(url: str) -> None:
|
||||
key = _host_key(url)
|
||||
_dead_hosts.pop(key, None)
|
||||
_host_fails.pop(key, None)
|
||||
with _host_health_lock:
|
||||
_dead_hosts.pop(key, None)
|
||||
_host_fails.pop(key, None)
|
||||
|
||||
|
||||
# Shared async HTTP client. Reusing one client keeps connections warm:
|
||||
@@ -130,7 +140,10 @@ def _set_cached_response(cache_key: str, response: str) -> None:
|
||||
if len(_response_cache) > 128:
|
||||
keys_to_remove = list(_response_cache.keys())[:64]
|
||||
for key in keys_to_remove:
|
||||
del _response_cache[key]
|
||||
# pop(), not del: another thread (sync llm_call runs in FastAPI's
|
||||
# threadpool) may have already evicted the same snapshotted key,
|
||||
# and del would raise KeyError mid-eviction (issue #659).
|
||||
_response_cache.pop(key, None)
|
||||
_response_cache[cache_key] = response
|
||||
|
||||
# ── Anthropic native API adapter ──
|
||||
|
||||
@@ -14,7 +14,7 @@ import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
|
||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
|
||||
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
||||
@@ -169,12 +169,15 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
or context_window fields. Caches result per model ID.
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
if model in _context_cache:
|
||||
is_local = _is_local_endpoint(endpoint_url)
|
||||
if not is_local and model in _context_cache:
|
||||
return _context_cache[model]
|
||||
|
||||
ctx = _query_context_length(endpoint_url, model)
|
||||
# Only cache non-default values to allow retry on next request
|
||||
if ctx != DEFAULT_CONTEXT:
|
||||
# Only cache non-default values to allow retry on next request.
|
||||
# Local endpoints can restart with a different --max-model-len while keeping
|
||||
# the same model id, so always re-query them instead of serving stale cache.
|
||||
if not is_local and ctx != DEFAULT_CONTEXT:
|
||||
_context_cache[model] = ctx
|
||||
logger.info(f"Context length for {model}: {ctx}")
|
||||
return ctx
|
||||
|
||||
@@ -651,15 +651,15 @@ async def execute_tool_block(
|
||||
elif tool == "create_document":
|
||||
title = content.split("\n")[0].strip()[:60]
|
||||
desc = f"create_document: {title}"
|
||||
result = await do_create_document(content, session_id=session_id)
|
||||
result = await do_create_document(content, session_id=session_id, owner=owner)
|
||||
elif tool == "update_document":
|
||||
desc = f"update_document: {content.split(chr(10))[0][:60]}"
|
||||
result = await do_update_document(content)
|
||||
result = await do_update_document(content, owner=owner)
|
||||
elif tool == "edit_document":
|
||||
result = await do_edit_document(content)
|
||||
result = await do_edit_document(content, owner=owner)
|
||||
desc = f"edit_document: {result.get('title', '')}"
|
||||
elif tool == "suggest_document":
|
||||
result = await do_suggest_document(content)
|
||||
result = await do_suggest_document(content, owner=owner)
|
||||
desc = f"suggest_document: {result.get('count', 0)} suggestions"
|
||||
elif tool == "search_chats":
|
||||
query = content.split("\n")[0].strip()
|
||||
|
||||
+41
-16
@@ -88,6 +88,28 @@ def get_active_document():
|
||||
return _active_document_id
|
||||
|
||||
|
||||
def _owned_document_query(query, Document, owner: Optional[str]):
|
||||
if owner is None:
|
||||
return query.filter(False)
|
||||
return query.filter(Document.owner == owner)
|
||||
|
||||
|
||||
def _get_owned_document(db, Document, doc_id: str, owner: Optional[str], active_only: bool = False):
|
||||
q = db.query(Document).filter(Document.id == doc_id)
|
||||
if active_only:
|
||||
q = q.filter(Document.is_active == True)
|
||||
q = _owned_document_query(q, Document, owner)
|
||||
return q.first()
|
||||
|
||||
|
||||
def _most_recent_owned_document(db, Document, owner: Optional[str], active_only: bool = False):
|
||||
q = db.query(Document)
|
||||
if active_only:
|
||||
q = q.filter(Document.is_active == True)
|
||||
q = _owned_document_query(q, Document, owner)
|
||||
return q.order_by(Document.updated_at.desc()).first()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document tools — create/update/edit/suggest living documents
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,7 +193,7 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
return header.rstrip() + "\n---\n" + body
|
||||
|
||||
|
||||
async def do_create_document(content_block: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_create_document(content_block: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Create a new document. Supports two formats:
|
||||
1) Line-based: line 1 = title, line 2 (optional) = language, rest = content
|
||||
2) XML-like tags: <title>...</title><language>...</language><content>...</content>
|
||||
@@ -240,6 +262,8 @@ async def do_create_document(content_block: str, session_id: Optional[str] = Non
|
||||
# Inherit ownership from the chat session so the doc survives that
|
||||
# session later being deleted (session_id → NULL).
|
||||
_sess = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if owner is not None and (not _sess or _sess.owner != owner):
|
||||
return {"error": "Cannot create document in another user's session"}
|
||||
_owner = _sess.owner if _sess else None
|
||||
|
||||
doc = Document(
|
||||
@@ -286,7 +310,7 @@ async def do_create_document(content_block: str, session_id: Optional[str] = Non
|
||||
db.close()
|
||||
|
||||
|
||||
async def do_update_document(content: str, doc_id: Optional[str] = None) -> Dict:
|
||||
async def do_update_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Update an existing document. Content = full new document text."""
|
||||
import uuid
|
||||
from src.database import SessionLocal, Document, DocumentVersion
|
||||
@@ -297,9 +321,9 @@ async def do_update_document(content: str, doc_id: Optional[str] = None) -> Dict
|
||||
try:
|
||||
doc = None
|
||||
if target_id:
|
||||
doc = db.query(Document).filter(Document.id == target_id).first()
|
||||
doc = _get_owned_document(db, Document, target_id, owner)
|
||||
if not doc:
|
||||
doc = db.query(Document).order_by(Document.updated_at.desc()).first()
|
||||
doc = _most_recent_owned_document(db, Document, owner)
|
||||
if doc:
|
||||
target_id = doc.id
|
||||
set_active_document(target_id)
|
||||
@@ -350,7 +374,7 @@ def parse_edit_blocks(content: str) -> list:
|
||||
return edits
|
||||
|
||||
|
||||
async def do_edit_document(content: str, doc_id: Optional[str] = None) -> Dict:
|
||||
async def do_edit_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Apply targeted FIND/REPLACE edits to an existing document."""
|
||||
import uuid
|
||||
from src.database import SessionLocal, Document, DocumentVersion
|
||||
@@ -365,11 +389,11 @@ async def do_edit_document(content: str, doc_id: Optional[str] = None) -> Dict:
|
||||
try:
|
||||
doc = None
|
||||
if target_id:
|
||||
doc = db.query(Document).filter(Document.id == target_id).first()
|
||||
doc = _get_owned_document(db, Document, target_id, owner)
|
||||
if not doc:
|
||||
# Fallback: most recently updated document. Avoids "no active doc" errors
|
||||
# after server restart or when the agent loses track of which doc to edit.
|
||||
doc = db.query(Document).order_by(Document.updated_at.desc()).first()
|
||||
doc = _most_recent_owned_document(db, Document, owner)
|
||||
if doc:
|
||||
target_id = doc.id
|
||||
set_active_document(target_id)
|
||||
@@ -458,7 +482,7 @@ def parse_suggest_blocks(content: str) -> list:
|
||||
return suggestions
|
||||
|
||||
|
||||
async def do_suggest_document(content: str, doc_id: str = None) -> Dict:
|
||||
async def do_suggest_document(content: str, doc_id: str = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Create inline suggestions for the active document WITHOUT modifying it."""
|
||||
from src.database import SessionLocal, Document
|
||||
|
||||
@@ -472,7 +496,7 @@ async def do_suggest_document(content: str, doc_id: str = None) -> Dict:
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == target_id).first()
|
||||
doc = _get_owned_document(db, Document, target_id, owner)
|
||||
if not doc:
|
||||
return {"error": f"Document {target_id} not found"}
|
||||
|
||||
@@ -713,7 +737,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||
if not sk_new.owner:
|
||||
sk_new.owner = match.get("owner") or owner
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new))
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
|
||||
return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1}
|
||||
|
||||
if action == "patch":
|
||||
@@ -737,7 +761,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except Exception as e:
|
||||
return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
|
||||
sk_new.name = slugify(sk_new.name or name)
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new))
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
|
||||
return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1}
|
||||
|
||||
if action == "publish":
|
||||
@@ -750,13 +774,13 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
updates = {"status": "published"}
|
||||
if args.get("confidence") is not None:
|
||||
updates["confidence"] = max(0.0, min(1.0, float(args["confidence"])))
|
||||
sm.update_skill(name, updates)
|
||||
sm.update_skill(name, updates, owner=owner)
|
||||
return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."}
|
||||
|
||||
if action == "delete":
|
||||
if not name:
|
||||
return {"error": "name is required for delete", "exit_code": 1}
|
||||
ok = sm.delete_skill(name)
|
||||
ok = sm.delete_skill(name, owner=owner)
|
||||
return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||
|
||||
if action == "search":
|
||||
@@ -1368,6 +1392,7 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
|
||||
try:
|
||||
if action == "list":
|
||||
q = db.query(Document).filter(Document.is_active == True)
|
||||
q = _owned_document_query(q, Document, owner)
|
||||
if args.get("search"):
|
||||
q = q.filter(Document.title.ilike(f"%{args['search']}%"))
|
||||
if args.get("language"):
|
||||
@@ -1398,7 +1423,7 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
|
||||
doc_id = args.get("document_id") or args.get("id") or args.get("uid")
|
||||
if not doc_id:
|
||||
return {"error": "Need document_id (use action=list to find one)", "exit_code": 1}
|
||||
doc = db.query(Document).filter(Document.id == doc_id, Document.is_active == True).first()
|
||||
doc = _get_owned_document(db, Document, doc_id, owner, active_only=True)
|
||||
if not doc:
|
||||
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
|
||||
body = doc.current_content or ""
|
||||
@@ -1423,10 +1448,10 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
|
||||
doc_id = args.get("document_id") or args.get("id") or args.get("uid") or _active_document_id
|
||||
doc = None
|
||||
if doc_id:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
doc = _get_owned_document(db, Document, doc_id, owner)
|
||||
if not doc:
|
||||
# Fallback: most recently updated doc (likely what the user means)
|
||||
doc = db.query(Document).filter(Document.is_active == True).order_by(Document.updated_at.desc()).first()
|
||||
doc = _most_recent_owned_document(db, Document, owner, active_only=True)
|
||||
if not doc:
|
||||
return {"error": "No document to delete", "exit_code": 1}
|
||||
title = doc.title
|
||||
|
||||
@@ -110,13 +110,18 @@ export function _foldSummary(label, iconSvg, meta) {
|
||||
subMeta = '';
|
||||
}
|
||||
}
|
||||
// `meta` is derived from _extractQuoteMeta, which strips tags but then
|
||||
// un-escapes entities (to recover `<foo@bar.com>` for bubble alignment) —
|
||||
// so it can carry attacker-controlled angle brackets from a quoted block.
|
||||
// This summary is built into innerHTML, so escape both parts to stop a
|
||||
// crafted quote (e.g. `From: <img src=x onerror=...>`) from running script.
|
||||
const metaSpan = subMeta
|
||||
? `<span class="email-fold-summary-meta">${subMeta}</span>`
|
||||
? `<span class="email-fold-summary-meta">${_esc(subMeta)}</span>`
|
||||
: '';
|
||||
return (
|
||||
'<summary class="email-fold-summary">'
|
||||
+ iconSvg
|
||||
+ `<span class="email-fold-summary-name">${primary}</span>`
|
||||
+ `<span class="email-fold-summary-name">${_esc(primary)}</span>`
|
||||
+ metaSpan
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</summary>'
|
||||
@@ -158,9 +163,12 @@ export function _extractQuoteMeta(html) {
|
||||
if (from.length > 60) from = from.slice(0, 57) + '…';
|
||||
if (date.length > 28) date = date.slice(0, 25) + '…';
|
||||
|
||||
if (from && date) return `${_esc(from)} · ${_esc(date)}`;
|
||||
if (from) return _esc(from);
|
||||
if (date) return _esc(date);
|
||||
// Return the raw sender/date text; `_foldSummary` is the single sink that
|
||||
// builds these into HTML, so it owns escaping. Escaping here too would
|
||||
// double-encode (e.g. "Ben & Jerry" -> "Ben &amp; Jerry").
|
||||
if (from && date) return `${from} · ${date}`;
|
||||
if (from) return from;
|
||||
if (date) return date;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
+79
-2
@@ -34,6 +34,83 @@ function linkHtml(text, url) {
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from
|
||||
* the source text — <details> blocks (collapsible agent output) and <a> tags
|
||||
* (emitted by the markdown link pass). Those fragments are later restored
|
||||
* verbatim into innerHTML, so without scrubbing them a model — or any content
|
||||
* routed through here — could smuggle in an `<img onerror=...>`, an
|
||||
* `<a href="javascript:...">`, an `onmouseover=` handler, etc. and execute
|
||||
* script in the authenticated page (DOM XSS).
|
||||
*
|
||||
* Parsing into a <template> is inert: assigning to template.innerHTML neither
|
||||
* fetches resources nor runs scripts, so we can walk the resulting tree,
|
||||
* drop script-capable elements, and strip event-handler attributes and
|
||||
* dangerous URL schemes before the (now safe) fragment is handed back.
|
||||
*/
|
||||
const _ALLOWED_HTML_BAD_TAGS = new Set([
|
||||
'SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',
|
||||
'STYLE', 'BASE', 'FORM', 'NOSCRIPT', 'TEMPLATE',
|
||||
// Foreign-content roots. SVG/MathML have their own parser rules and are a
|
||||
// classic mutation-XSS vehicle — e.g. an SVG-namespaced <script>, whose
|
||||
// `tagName` is the lower-case 'script' and would slip a name check that
|
||||
// assumed HTML's upper-casing. They aren't needed in the <details>/<a>
|
||||
// fragments we preserve, so drop the whole subtree.
|
||||
'SVG', 'MATH',
|
||||
]);
|
||||
const _ALLOWED_HTML_URL_ATTRS = new Set([
|
||||
'href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
||||
]);
|
||||
|
||||
function _cleanAllowedHtmlOnce(htmlString) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlString;
|
||||
for (const el of Array.from(tpl.content.querySelectorAll('*'))) {
|
||||
// Upper-case the tag for comparison: HTML tagNames are upper-case, but
|
||||
// SVG/MathML elements preserve their original (lower/camel) case, so a
|
||||
// raw `Set.has(el.tagName)` would miss e.g. a namespaced <script>.
|
||||
if (_ALLOWED_HTML_BAD_TAGS.has(el.tagName.toUpperCase())) {
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
// Drop every inline event handler (onerror, onclick, onmouseover, ...)
|
||||
// and srcdoc (a frame-less script vector).
|
||||
if (name.startsWith('on') || name === 'srcdoc') {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
|
||||
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
|
||||
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
|
||||
const value = (attr.value || '').replace(/[\x00-\x20]+/g, '').toLowerCase();
|
||||
if (/^(javascript|vbscript|data):/.test(value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tpl.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeAllowedHtml(html) {
|
||||
const raw = String(html == null ? '' : html);
|
||||
// Non-browser context (e.g. a future SSR/Node import): fail closed by
|
||||
// escaping rather than trusting the markup.
|
||||
if (typeof document === 'undefined') return escapeHtml(raw);
|
||||
|
||||
// Sanitize to a fixpoint. Re-parsing the serialized output can mutate the
|
||||
// tree (the basis of mutation-XSS), so re-clean until it stops changing.
|
||||
let out = raw;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const next = _cleanAllowedHtmlOnce(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text has unclosed think tag
|
||||
*/
|
||||
@@ -356,14 +433,14 @@ export function mdToHtml(src) {
|
||||
// Default to open so agent output is visible
|
||||
s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
|
||||
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
||||
allowedHtmlBlocks.push(match.replace(/<details>/i, '<details open>'));
|
||||
allowedHtmlBlocks.push(sanitizeAllowedHtml(match.replace(/<details>/i, '<details open>')));
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
|
||||
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
|
||||
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
||||
allowedHtmlBlocks.push(match);
|
||||
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
|
||||
@@ -1464,6 +1464,7 @@ function _parseTimeSpec(input) {
|
||||
const mer = (m[4] || '').toLowerCase();
|
||||
if (mer === 'pm' && hh < 12) hh += 12;
|
||||
if (mer === 'am' && hh === 12) hh = 0;
|
||||
if (hh > 23 || mm > 59) return null;
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return { date: d, rest: m[5].trim() };
|
||||
}
|
||||
@@ -1477,9 +1478,9 @@ function _parseTimeSpec(input) {
|
||||
const mer = (m[3] || '').toLowerCase();
|
||||
if (mer === 'pm' && hh < 12) hh += 12;
|
||||
if (mer === 'am' && hh === 12) hh = 0;
|
||||
// Require an hour <= 23 and either a minute field or am/pm to avoid
|
||||
// eating plain numbers like "3 apples".
|
||||
if (hh > 23) return null;
|
||||
// Require a valid hour/minute and either a minute field or am/pm to
|
||||
// avoid eating plain numbers like "3 apples".
|
||||
if (hh > 23 || mm > 59) return null;
|
||||
if (m[2] == null && !mer) return null;
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import sys
|
||||
import types
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -79,23 +78,18 @@ def _login_endpoint(auth_manager):
|
||||
raise AssertionError("login route not found on the auth router")
|
||||
|
||||
|
||||
def test_login_runs_bcrypt_off_the_event_loop():
|
||||
loop_thread = threading.get_ident()
|
||||
seen = {}
|
||||
|
||||
def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
||||
calls = []
|
||||
auth = MagicMock()
|
||||
|
||||
def _verify(username, password):
|
||||
seen["verify_thread"] = threading.get_ident()
|
||||
return True
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
calls.append(fn)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
def _create(username, password):
|
||||
seen["create_thread"] = threading.get_ident()
|
||||
return "tok-123"
|
||||
|
||||
auth.verify_password.side_effect = _verify
|
||||
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
|
||||
auth.verify_password.return_value = True
|
||||
auth.totp_enabled.return_value = False
|
||||
auth.create_session.side_effect = _create
|
||||
auth.create_session.return_value = "tok-123"
|
||||
|
||||
login = _login_endpoint(auth)
|
||||
|
||||
@@ -108,6 +102,6 @@ def test_login_runs_bcrypt_off_the_event_loop():
|
||||
assert result["ok"] is True
|
||||
auth.verify_password.assert_called_once()
|
||||
auth.create_session.assert_called_once()
|
||||
# The whole point: the expensive bcrypt calls must NOT run on the loop thread.
|
||||
assert seen["verify_thread"] != loop_thread, "verify_password ran on the event-loop thread"
|
||||
assert seen["create_thread"] != loop_thread, "create_session ran on the event-loop thread"
|
||||
# The whole point: the expensive bcrypt-bearing calls go through
|
||||
# asyncio.to_thread rather than running inline in the request coroutine.
|
||||
assert calls == [auth.verify_password, auth.create_session]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression tests for password-change session revocation."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _real_core_package():
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
core_path = str(root / "core")
|
||||
core = sys.modules.get("core")
|
||||
if core is None:
|
||||
core = types.ModuleType("core")
|
||||
sys.modules["core"] = core
|
||||
core.__path__ = [core_path]
|
||||
if hasattr(core, "auth"):
|
||||
delattr(core, "auth")
|
||||
sys.modules.pop("core.auth", None)
|
||||
return core
|
||||
|
||||
|
||||
def _auth_module():
|
||||
_real_core_package()
|
||||
return importlib.import_module("core.auth")
|
||||
|
||||
|
||||
def _make_manager(tmp_path):
|
||||
auth_mod = _auth_module()
|
||||
auth_mod._hash_password = lambda password: f"hash:{password}"
|
||||
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
|
||||
auth_path = tmp_path / "auth.json"
|
||||
mgr = auth_mod.AuthManager(str(auth_path))
|
||||
assert mgr.create_user("alice", "old-password", is_admin=False)
|
||||
assert mgr.create_user("bob", "bob-password", is_admin=False)
|
||||
return mgr
|
||||
|
||||
|
||||
async def _immediate_to_thread(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
def test_revoke_user_sessions_preserves_current_and_persists(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
current = mgr.create_session("alice", "old-password")
|
||||
other = mgr.create_session("alice", "old-password")
|
||||
bob = mgr.create_session("bob", "bob-password")
|
||||
|
||||
revoked = mgr.revoke_user_sessions("alice", except_token=current)
|
||||
|
||||
assert revoked == 1
|
||||
assert mgr.validate_token(current) is True
|
||||
assert mgr.validate_token(other) is False
|
||||
assert mgr.validate_token(bob) is True
|
||||
|
||||
|
||||
def test_wrong_current_password_does_not_revoke_sessions(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
current = mgr.create_session("alice", "old-password")
|
||||
other = mgr.create_session("alice", "old-password")
|
||||
|
||||
assert mgr.change_password("alice", "wrong-password", "new-password") is False
|
||||
|
||||
assert mgr.validate_token(current) is True
|
||||
assert mgr.validate_token(other) is True
|
||||
|
||||
|
||||
def test_password_change_allows_new_password_and_blocks_old_password(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
|
||||
assert mgr.change_password("alice", "old-password", "new-password") is True
|
||||
|
||||
assert mgr.create_session("alice", "old-password") is None
|
||||
assert mgr.create_session("alice", "new-password") is not None
|
||||
|
||||
|
||||
def _change_password_endpoint(auth_manager):
|
||||
sys.modules.pop("routes.auth_routes", None)
|
||||
_real_core_package()
|
||||
from routes.auth_routes import ChangePasswordRequest, setup_auth_routes
|
||||
|
||||
router = setup_auth_routes(auth_manager)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == "/api/auth/change-password":
|
||||
return route.endpoint, ChangePasswordRequest
|
||||
raise AssertionError("change-password route not found")
|
||||
|
||||
|
||||
def test_change_password_route_revokes_other_sessions_after_success(monkeypatch):
|
||||
auth = MagicMock()
|
||||
auth.get_username_for_token.return_value = "alice"
|
||||
auth.change_password.return_value = True
|
||||
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
|
||||
monkeypatch.setattr(
|
||||
"routes.auth_routes.asyncio.to_thread",
|
||||
lambda fn, *args, **kwargs: _immediate_to_thread(fn, *args, **kwargs),
|
||||
)
|
||||
request = SimpleNamespace(cookies={"odysseus_session": "current-token"})
|
||||
body = ChangePasswordRequest(current_password="old-password", new_password="new-password")
|
||||
|
||||
result = asyncio.run(endpoint(body=body, request=request))
|
||||
|
||||
assert result == {"ok": True}
|
||||
auth.change_password.assert_called_once_with("alice", "old-password", "new-password")
|
||||
auth.revoke_user_sessions.assert_called_once_with("alice", "current-token")
|
||||
|
||||
|
||||
def test_change_password_route_wrong_password_does_not_revoke(monkeypatch):
|
||||
auth = MagicMock()
|
||||
auth.get_username_for_token.return_value = "alice"
|
||||
auth.change_password.return_value = False
|
||||
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
|
||||
monkeypatch.setattr(
|
||||
"routes.auth_routes.asyncio.to_thread",
|
||||
lambda fn, *args, **kwargs: _immediate_to_thread(fn, *args, **kwargs),
|
||||
)
|
||||
request = SimpleNamespace(cookies={"odysseus_session": "current-token"})
|
||||
body = ChangePasswordRequest(current_password="wrong-password", new_password="new-password")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(body=body, request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
auth.revoke_user_sessions.assert_not_called()
|
||||
@@ -0,0 +1,120 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_backup_cli():
|
||||
path = Path(__file__).resolve().parent.parent / "scripts" / "odysseus-backup"
|
||||
loader = importlib.machinery.SourceFileLoader("odysseus_backup_under_test", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _patch_repo(module, monkeypatch, root: Path):
|
||||
monkeypatch.setattr(module, "_REPO_ROOT", root)
|
||||
monkeypatch.setattr(module, "_DATA_DIR", root / "data")
|
||||
|
||||
|
||||
def _restore_args(path: Path):
|
||||
return SimpleNamespace(path=str(path), yes=True, pretty=False)
|
||||
|
||||
|
||||
def _verify_args(path: Path):
|
||||
return SimpleNamespace(path=str(path), pretty=False)
|
||||
|
||||
|
||||
def test_restore_rejects_symlink_escape(tmp_path, monkeypatch):
|
||||
backup = _load_backup_cli()
|
||||
repo = tmp_path / "repo"
|
||||
data = repo / "data"
|
||||
outside = tmp_path / "outside"
|
||||
data.mkdir(parents=True)
|
||||
outside.mkdir()
|
||||
(data / "keep.txt").write_text("still here", encoding="utf-8")
|
||||
_patch_repo(backup, monkeypatch, repo)
|
||||
|
||||
tar_path = tmp_path / "malicious.tar.gz"
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
data_dir = tarfile.TarInfo("data")
|
||||
data_dir.type = tarfile.DIRTYPE
|
||||
tar.addfile(data_dir)
|
||||
|
||||
link = tarfile.TarInfo("data/link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = str(outside)
|
||||
tar.addfile(link)
|
||||
|
||||
payload = b"escaped"
|
||||
escaped = tarfile.TarInfo("data/link/pwned.txt")
|
||||
escaped.size = len(payload)
|
||||
tar.addfile(escaped, io.BytesIO(payload))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
backup.cmd_restore(_restore_args(tar_path))
|
||||
|
||||
assert not (outside / "pwned.txt").exists()
|
||||
assert (data / "keep.txt").read_text(encoding="utf-8") == "still here"
|
||||
|
||||
|
||||
def test_verify_rejects_symlink_escape(tmp_path):
|
||||
backup = _load_backup_cli()
|
||||
|
||||
tar_path = tmp_path / "malicious.tar.gz"
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
link = tarfile.TarInfo("data/link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "/tmp"
|
||||
tar.addfile(link)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
backup.cmd_verify(_verify_args(tar_path))
|
||||
|
||||
|
||||
def test_restore_rejects_hardlink_entries(tmp_path, monkeypatch):
|
||||
backup = _load_backup_cli()
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "data").mkdir(parents=True)
|
||||
_patch_repo(backup, monkeypatch, repo)
|
||||
|
||||
tar_path = tmp_path / "hardlink.tar.gz"
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
link = tarfile.TarInfo("data/hardlink")
|
||||
link.type = tarfile.LNKTYPE
|
||||
link.linkname = "../outside.txt"
|
||||
tar.addfile(link)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
backup.cmd_restore(_restore_args(tar_path))
|
||||
|
||||
|
||||
def test_restore_extracts_regular_files_without_extractall(tmp_path, monkeypatch):
|
||||
backup = _load_backup_cli()
|
||||
repo = tmp_path / "repo"
|
||||
data = repo / "data"
|
||||
data.mkdir(parents=True)
|
||||
(data / "old.txt").write_text("old", encoding="utf-8")
|
||||
_patch_repo(backup, monkeypatch, repo)
|
||||
|
||||
tar_path = tmp_path / "valid.tar.gz"
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
folder = tarfile.TarInfo("data/nested")
|
||||
folder.type = tarfile.DIRTYPE
|
||||
tar.addfile(folder)
|
||||
|
||||
payload = b"new"
|
||||
item = tarfile.TarInfo("data/nested/new.txt")
|
||||
item.size = len(payload)
|
||||
tar.addfile(item, io.BytesIO(payload))
|
||||
|
||||
backup.cmd_restore(_restore_args(tar_path))
|
||||
|
||||
assert (repo / "data" / "nested" / "new.txt").read_text(encoding="utf-8") == "new"
|
||||
assert not (repo / "data" / "old.txt").exists()
|
||||
assert list(repo.glob("data.before-restore-*"))
|
||||
@@ -11,38 +11,19 @@ and passes the account owner to do_manage_calendar. This test pins that
|
||||
get_upcoming_events scopes to the owner; it fails if the owner filter is
|
||||
dropped (the original cross-tenant behavior).
|
||||
"""
|
||||
import os
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from core import database as db
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_get_upcoming_events_is_owner_scoped():
|
||||
db.Base.metadata.create_all(bind=db.engine)
|
||||
soon = datetime.utcnow() + timedelta(days=2)
|
||||
end = soon + timedelta(hours=1)
|
||||
source = Path("core/database.py").read_text()
|
||||
tree = ast.parse(source)
|
||||
fn = next(
|
||||
node for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "get_upcoming_events"
|
||||
)
|
||||
body = ast.unparse(fn)
|
||||
|
||||
s = db.SessionLocal()
|
||||
try:
|
||||
s.merge(db.CalendarCal(id="cal-alice", owner="alice", name="Alice"))
|
||||
s.merge(db.CalendarCal(id="cal-bob", owner="bob", name="Bob"))
|
||||
s.merge(db.CalendarEvent(uid="ev-alice", calendar_id="cal-alice",
|
||||
summary="Alice 1:1", dtstart=soon, dtend=end))
|
||||
s.merge(db.CalendarEvent(uid="ev-bob", calendar_id="cal-bob",
|
||||
summary="Bob 1:1", dtstart=soon, dtend=end))
|
||||
s.commit()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
alice = {e["uid"] for e in db.get_upcoming_events("alice")}
|
||||
bob = {e["uid"] for e in db.get_upcoming_events("bob")}
|
||||
everyone = {e["uid"] for e in db.get_upcoming_events(None)}
|
||||
|
||||
# An owner sees ONLY their own events — never the other tenant's.
|
||||
assert alice == {"ev-alice"}, alice
|
||||
assert bob == {"ev-bob"}, bob
|
||||
assert "ev-bob" not in alice and "ev-alice" not in bob
|
||||
# owner=None is the explicit single-user / legacy escape hatch (unscoped).
|
||||
assert {"ev-alice", "ev-bob"} <= everyone
|
||||
assert "join(CalendarCal)" in body
|
||||
assert "if owner is not None:" in body
|
||||
assert "q.filter(CalendarCal.owner == owner)" in body
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
|
||||
from src import tool_implementations as tools
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return (self.name, "eq", value)
|
||||
|
||||
def desc(self):
|
||||
return (self.name, "desc")
|
||||
|
||||
def ilike(self, value):
|
||||
return (self.name, "ilike", value)
|
||||
|
||||
|
||||
class _Document:
|
||||
id = _Column("id")
|
||||
owner = _Column("owner")
|
||||
is_active = _Column("is_active")
|
||||
title = _Column("title")
|
||||
language = _Column("language")
|
||||
updated_at = _Column("updated_at")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, docs=None, first_doc=None):
|
||||
self.filters = []
|
||||
self.docs = docs or []
|
||||
self.first_doc = first_doc
|
||||
|
||||
def filter(self, *clauses):
|
||||
self.filters.extend(clauses)
|
||||
return self
|
||||
|
||||
def order_by(self, *args):
|
||||
return self
|
||||
|
||||
def limit(self, *args):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self.docs
|
||||
|
||||
def first(self):
|
||||
return self.first_doc
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, query):
|
||||
self.query_obj = query
|
||||
|
||||
def query(self, *args):
|
||||
return self.query_obj
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _install_database_stub(monkeypatch, module_name, query):
|
||||
db = _Db(query)
|
||||
db_mod = types.ModuleType(module_name)
|
||||
db_mod.SessionLocal = lambda: db
|
||||
db_mod.Document = _Document
|
||||
db_mod.DocumentVersion = object
|
||||
db_mod.Session = object
|
||||
monkeypatch.setitem(sys.modules, module_name, db_mod)
|
||||
return db
|
||||
|
||||
|
||||
def test_owned_document_query_rejects_missing_owner():
|
||||
query = _Query()
|
||||
|
||||
assert tools._owned_document_query(query, _Document, None) is query
|
||||
assert False in query.filters
|
||||
|
||||
|
||||
def test_owned_document_query_filters_to_owner():
|
||||
query = _Query()
|
||||
|
||||
assert tools._owned_document_query(query, _Document, "alice") is query
|
||||
assert ("owner", "eq", "alice") in query.filters
|
||||
|
||||
|
||||
def test_manage_documents_list_filters_to_calling_owner(monkeypatch):
|
||||
query = _Query()
|
||||
_install_database_stub(monkeypatch, "core.database", query)
|
||||
|
||||
result = asyncio.run(tools.do_manage_documents('{"action":"list"}', owner="alice"))
|
||||
|
||||
assert result["documents"] == []
|
||||
assert ("owner", "eq", "alice") in query.filters
|
||||
|
||||
|
||||
def test_manage_documents_read_filters_to_calling_owner(monkeypatch):
|
||||
query = _Query()
|
||||
_install_database_stub(monkeypatch, "core.database", query)
|
||||
|
||||
result = asyncio.run(
|
||||
tools.do_manage_documents('{"action":"read","document_id":"doc-bob"}', owner="alice")
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert ("id", "eq", "doc-bob") in query.filters
|
||||
assert ("owner", "eq", "alice") in query.filters
|
||||
|
||||
|
||||
def test_update_document_active_id_filters_to_calling_owner(monkeypatch):
|
||||
query = _Query()
|
||||
_install_database_stub(monkeypatch, "src.database", query)
|
||||
tools.set_active_document("doc-bob")
|
||||
try:
|
||||
result = asyncio.run(tools.do_update_document("new content", owner="alice"))
|
||||
finally:
|
||||
tools.set_active_document(None)
|
||||
|
||||
assert result["error"] == "No documents exist to update"
|
||||
assert ("id", "eq", "doc-bob") in query.filters
|
||||
assert ("owner", "eq", "alice") in query.filters
|
||||
|
||||
|
||||
def test_suggest_document_active_id_filters_to_calling_owner(monkeypatch):
|
||||
query = _Query()
|
||||
_install_database_stub(monkeypatch, "src.database", query)
|
||||
tools.set_active_document("doc-bob")
|
||||
try:
|
||||
result = asyncio.run(tools.do_suggest_document(
|
||||
"<<<FIND>>>\nold\n<<<SUGGEST>>>\nnew\n<<<REASON>>>\nbetter\n<<<END>>>",
|
||||
owner="alice",
|
||||
))
|
||||
finally:
|
||||
tools.set_active_document(None)
|
||||
|
||||
assert result["error"] == "Document doc-bob not found"
|
||||
assert ("id", "eq", "doc-bob") in query.filters
|
||||
assert ("owner", "eq", "alice") in query.filters
|
||||
|
||||
|
||||
def test_document_tool_dispatch_forwards_owner():
|
||||
source = open("src/tool_execution.py", encoding="utf-8").read()
|
||||
|
||||
assert "do_create_document(content, session_id=session_id, owner=owner)" in source
|
||||
assert "do_update_document(content, owner=owner)" in source
|
||||
assert "do_edit_document(content, owner=owner)" in source
|
||||
assert "do_suggest_document(content, owner=owner)" in source
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Regression tests for thread-safe access to llm_core's shared maps (issue #659).
|
||||
|
||||
The synchronous llm_call() runs inside FastAPI's threadpool (sync route handlers
|
||||
such as POST /sessions/auto-sort), while llm_call_async() runs on the event
|
||||
loop. Both mutate the module-level _response_cache / _host_fails / _dead_hosts
|
||||
dicts, so those mutations must tolerate concurrent access from multiple OS
|
||||
threads.
|
||||
|
||||
Plain thread stress can't reliably reproduce these races (CPython's GIL rarely
|
||||
preempts the short critical sections), so each test deterministically widens the
|
||||
vulnerable window: one injects a phantom snapshot key, the other forces every
|
||||
thread to read the counter before any writes it back.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from src import llm_core
|
||||
|
||||
|
||||
def test_cache_eviction_tolerates_already_removed_key():
|
||||
"""Eviction must not raise when a snapshotted key is gone by delete time.
|
||||
|
||||
Models a concurrent evictor removing the same key: the old `del` raised
|
||||
KeyError mid-loop, `pop(key, None)` does not.
|
||||
"""
|
||||
class PhantomKeysCache(dict):
|
||||
def keys(self):
|
||||
# First key is absent from the dict — as if another thread evicted
|
||||
# it between the snapshot and the delete.
|
||||
return ["__phantom_removed__", *super().keys()]
|
||||
|
||||
original = llm_core._response_cache
|
||||
cache = PhantomKeysCache()
|
||||
for i in range(130): # exceed the 128 cap so the eviction branch runs
|
||||
cache[f"k{i}"] = "x"
|
||||
llm_core._response_cache = cache
|
||||
try:
|
||||
llm_core._set_cached_response("new-key", "y") # must not raise
|
||||
assert dict.get(cache, "new-key") == "y"
|
||||
finally:
|
||||
llm_core._response_cache = original
|
||||
|
||||
|
||||
def test_host_fail_counter_has_no_lost_updates():
|
||||
"""Concurrent _mark_host_dead calls must each count exactly once.
|
||||
|
||||
A SlowGetDict widens the read-modify-write window so the unguarded
|
||||
get()+1+set() loses every update but one; the lock serializes them.
|
||||
"""
|
||||
url = "http://race.example:1234/v1/chat/completions"
|
||||
key = llm_core._host_key(url)
|
||||
|
||||
class SlowGetDict(dict):
|
||||
def get(self, *args, **kwargs):
|
||||
value = super().get(*args, **kwargs)
|
||||
time.sleep(0.01) # widen the gap between the read and the caller's write
|
||||
return value
|
||||
|
||||
n_threads = 8
|
||||
barrier = threading.Barrier(n_threads)
|
||||
original_fails = llm_core._host_fails
|
||||
original_threshold = llm_core._HOST_FAIL_THRESHOLD
|
||||
llm_core._host_fails = SlowGetDict()
|
||||
llm_core._HOST_FAIL_THRESHOLD = 10 ** 9 # never cool: every call is a pure +1
|
||||
try:
|
||||
def worker():
|
||||
barrier.wait() # all threads enter the read window together
|
||||
llm_core._mark_host_dead(url)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert dict.get(llm_core._host_fails, key) == n_threads
|
||||
finally:
|
||||
llm_core._host_fails = original_fails
|
||||
llm_core._HOST_FAIL_THRESHOLD = original_threshold
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
import src.model_context as model_context
|
||||
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
|
||||
|
||||
|
||||
@@ -107,3 +108,46 @@ class TestLookupKnown:
|
||||
"""Models with :free or :extended suffixes should still match."""
|
||||
result = _lookup_known("deepseek-r1:free")
|
||||
assert result == 64000
|
||||
|
||||
|
||||
class TestGetContextLength:
|
||||
def setup_method(self):
|
||||
model_context._context_cache.clear()
|
||||
|
||||
def test_local_endpoint_requeries_same_model_after_restart(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_query(endpoint_url, model):
|
||||
calls.append((endpoint_url, model))
|
||||
return 8192 if len(calls) == 1 else 27000
|
||||
|
||||
monkeypatch.setattr(model_context, "_query_context_length", fake_query)
|
||||
|
||||
endpoint = "http://127.0.0.1:8000/v1/chat/completions"
|
||||
model = "Qwen/Qwen3-14B"
|
||||
|
||||
first = model_context.get_context_length(endpoint, model)
|
||||
second = model_context.get_context_length(endpoint, model)
|
||||
|
||||
assert first == 8192
|
||||
assert second == 27000
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_remote_endpoint_keeps_cached_context(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_query(endpoint_url, model):
|
||||
calls.append((endpoint_url, model))
|
||||
return 200000 if len(calls) == 1 else 12345
|
||||
|
||||
monkeypatch.setattr(model_context, "_query_context_length", fake_query)
|
||||
|
||||
endpoint = "https://api.openai.com/v1/chat/completions"
|
||||
model = "gpt-5"
|
||||
|
||||
first = model_context.get_context_length(endpoint, model)
|
||||
second = model_context.get_context_length(endpoint, model)
|
||||
|
||||
assert first == 200000
|
||||
assert second == 200000
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -296,3 +296,23 @@ class TestSetupProbeSafety:
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
assert _probe_endpoint("https://api.anthropic.com/v1") == ANTHROPIC_MODELS
|
||||
|
||||
def test_ollama_endpoint_error_message_includes_troubleshooting():
|
||||
msg = model_routes._model_endpoint_error_message(
|
||||
"http://localhost:11434/v1",
|
||||
{"error": "Connection refused"},
|
||||
)
|
||||
|
||||
assert "No Ollama models found" in msg
|
||||
assert "Connection refused" in msg
|
||||
assert "http://localhost:11434/v1" in msg
|
||||
assert "ollama list" in msg
|
||||
|
||||
|
||||
def test_generic_endpoint_error_message_preserves_probe_error():
|
||||
msg = model_routes._model_endpoint_error_message(
|
||||
"https://api.example.com/v1",
|
||||
{"error": "HTTP 401"},
|
||||
)
|
||||
|
||||
assert msg == "No models found for that provider/key. Last probe error: HTTP 401."
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Regression: reserved sentinel usernames must not be registerable.
|
||||
|
||||
`core.middleware.require_admin` grants admin to any request whose
|
||||
`current_user == "internal-tool"` (the in-process tool-loopback sentinel),
|
||||
and the cookie auth path in app.py sets `current_user` to the raw username.
|
||||
Before this fix nothing reserved that name, so a self-service signup (or an
|
||||
admin typo) creating the account "internal-tool" was silently treated as an
|
||||
admin by every `require_admin`-gated route — a privilege escalation. "api"
|
||||
is reserved for the same reason (bearer-token owner attribution collision).
|
||||
|
||||
See the privilege-escalation finding from the 2026-06 code review.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _fresh_auth_manager(tmp_path):
|
||||
# Same import dance as test_security_regressions: drop any cached stub so
|
||||
# we exercise the real module from disk rather than a conftest mock.
|
||||
sys.modules.pop("core.auth", None)
|
||||
if "core" in sys.modules and hasattr(sys.modules["core"], "auth"):
|
||||
delattr(sys.modules["core"], "auth")
|
||||
from core.auth import AuthManager
|
||||
|
||||
return AuthManager(str(tmp_path / "auth.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["internal-tool", "api", "demo", "system", "INTERNAL-TOOL", " Internal-Tool ", "Api", "SYSTEM"],
|
||||
)
|
||||
def test_create_user_rejects_reserved_usernames(tmp_path, name):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
assert mgr.create_user(name, "pw-123456") is False
|
||||
# The normalized name must not have been written to the user table.
|
||||
assert name.strip().lower() not in mgr.users
|
||||
|
||||
|
||||
def test_create_user_rejects_empty_username(tmp_path):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
assert mgr.create_user(" ", "pw-123456") is False
|
||||
assert "" not in mgr.users
|
||||
|
||||
|
||||
def test_setup_rejects_reserved_admin_username(tmp_path):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
# First-run admin setup funnels through create_user, so it's covered too.
|
||||
assert mgr.setup("internal-tool", "pw-123456") is False
|
||||
assert mgr.is_configured is False
|
||||
|
||||
|
||||
def test_rename_into_reserved_username_is_blocked(tmp_path):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
assert mgr.create_user("admin", "pw-123456", is_admin=True) is True
|
||||
assert mgr.create_user("bob", "pw-123456") is True
|
||||
assert mgr.rename_user("bob", "internal-tool", "admin") is False
|
||||
assert "internal-tool" not in mgr.users
|
||||
assert "bob" in mgr.users
|
||||
|
||||
|
||||
def test_normal_usernames_still_allowed(tmp_path):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
assert mgr.create_user("alice", "pw-123456") is True
|
||||
assert "alice" in mgr.users
|
||||
@@ -97,6 +97,42 @@ def _install_core_auth_stub(monkeypatch):
|
||||
return auth_mod
|
||||
|
||||
|
||||
def test_providers_requires_admin_before_discovery_and_cache(monkeypatch):
|
||||
_install_model_route_import_stubs(monkeypatch)
|
||||
import routes.model_routes as model_routes
|
||||
|
||||
class _Discovery:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def get_providers(self):
|
||||
self.calls += 1
|
||||
return {"providers": [{"host": "internal.example"}]}
|
||||
|
||||
discovery = _Discovery()
|
||||
router = model_routes.setup_model_routes(discovery)
|
||||
endpoint = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if getattr(route, "path", "") == "/api/providers"
|
||||
)
|
||||
request = SimpleNamespace()
|
||||
|
||||
assert endpoint(request, refresh=True) == {"providers": [{"host": "internal.example"}]}
|
||||
assert discovery.calls == 1
|
||||
|
||||
def deny_admin(_request):
|
||||
raise PermissionError("admin required")
|
||||
|
||||
monkeypatch.setattr(model_routes, "require_admin", deny_admin)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
endpoint(request, refresh=True)
|
||||
with pytest.raises(PermissionError):
|
||||
endpoint(request, refresh=False)
|
||||
assert discovery.calls == 1
|
||||
|
||||
|
||||
def test_default_chat_does_not_auto_pick_shared_endpoint_for_fresh_user(monkeypatch):
|
||||
_install_model_route_import_stubs(monkeypatch)
|
||||
import routes.model_routes as model_routes
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Independent validation test for the claim that
|
||||
`SkillsManager.update_skill` mutates the first skill on disk matching
|
||||
`name` regardless of the caller's owner, and that `owner` is in its
|
||||
`scalar_keys` whitelist allowing cross-user ownership reassignment.
|
||||
|
||||
This test sets up two user-owned skills on disk with the SAME slug
|
||||
(`login-flow`) — Alice's and Bob's — and then calls `update_skill` with
|
||||
NO `owner` argument. If the bug is real, exactly one of the two files
|
||||
will be mutated (whichever `_iter_skill_files` yields first) and the
|
||||
caller will have effectively re-stamped the file as owned by the value
|
||||
in `updates["owner"]` ("attacker"). If the manager method is safe (or
|
||||
the slug uniqueness invariant makes the bug moot), the call should
|
||||
either:
|
||||
* raise (it requires an `owner` argument), OR
|
||||
* be a no-op (no other side effect on Bob's file), OR
|
||||
* the file that gets modified should still belong to its original
|
||||
owner (no ownership reassignment).
|
||||
|
||||
We assert the safer behaviors; the test FAILS only when update_skill
|
||||
silently mutates a file owned by a different user AND overwrites the
|
||||
`owner` field with an attacker's value.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── module-load stubbing (matches other tests in this repo) ──────────
|
||||
# Stub heavy deps so importing the skills manager doesn't pull DB / FastAPI.
|
||||
for _mod in [
|
||||
"sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext",
|
||||
"sqlalchemy.ext.declarative", "src.database",
|
||||
"core.atomic_io", # we'll patch atomic_write_text below
|
||||
]:
|
||||
if _mod not in sys.modules:
|
||||
sys.modules[_mod] = MagicMock()
|
||||
|
||||
|
||||
# Provide a no-op atomic_write_text for SkillsManager._write_skill.
|
||||
def _fake_atomic_write_text(path, content, **kw):
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(path).write_text(content, encoding="utf-8")
|
||||
|
||||
_fake_core = types.ModuleType("core.atomic_io")
|
||||
_fake_core.atomic_write_text = _fake_atomic_write_text
|
||||
_fake_core.atomic_write_json = lambda p, d, **kw: Path(p).write_text(
|
||||
"{}", encoding="utf-8"
|
||||
)
|
||||
sys.modules["core.atomic_io"] = _fake_core
|
||||
|
||||
|
||||
from services.memory.skills import SkillsManager # noqa: E402
|
||||
from services.memory.skill_format import Skill, slugify # noqa: E402
|
||||
|
||||
|
||||
def _write_skill_md(skills_root: Path, category: str, name: str,
|
||||
owner: str, description: str) -> Path:
|
||||
"""Drop a real SKILL.md on disk for the given owner."""
|
||||
skill_dir = skills_root / slugify(category or "general", fallback="general") / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
md = textwrap.dedent(f"""\
|
||||
---
|
||||
name: {name}
|
||||
description: {description}
|
||||
version: 1.0.0
|
||||
category: {category}
|
||||
tags: []
|
||||
status: draft
|
||||
confidence: 0.8
|
||||
source: learned
|
||||
owner: {owner}
|
||||
created: 2026-01-01T00:00:00Z
|
||||
---
|
||||
|
||||
# When to use
|
||||
test
|
||||
|
||||
# Procedure
|
||||
- step 1
|
||||
""")
|
||||
path = skill_dir / "SKILL.md"
|
||||
path.write_text(md, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
|
||||
"""Two users own distinct skills with the same slug. update_skill()
|
||||
called WITHOUT an owner argument must not silently overwrite the
|
||||
wrong file or change its owner field."""
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create two distinct on-disk skills with the SAME slug but in
|
||||
# DIFFERENT category directories so they are real, separately
|
||||
# addressable files. (The on-disk layout is
|
||||
# `<category>/<name>/SKILL.md`, so two users can in fact have
|
||||
# the same slug under different categories — exactly the situation
|
||||
# that triggers the first-match-wins bug in update_skill.)
|
||||
alice_path = _write_skill_md(
|
||||
skills_root, category="alice-cat", name="login-flow",
|
||||
owner="alice", description="alice original",
|
||||
)
|
||||
bob_path = _write_skill_md(
|
||||
skills_root, category="bob-cat", name="login-flow",
|
||||
owner="bob", description="bob original",
|
||||
)
|
||||
assert alice_path != bob_path
|
||||
assert alice_path.exists() and bob_path.exists()
|
||||
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
|
||||
# Snapshot before.
|
||||
before_alice = alice_path.read_text(encoding="utf-8")
|
||||
before_bob = bob_path.read_text(encoding="utf-8")
|
||||
|
||||
# Try to reassign + mutate. The caller does NOT supply an owner
|
||||
# arg, mirroring the in-process callers in tool_implementations.py
|
||||
# (lines 716, 740, 753) which call sm.update_skill(name, updates).
|
||||
try:
|
||||
result = sm.update_skill(
|
||||
"login-flow",
|
||||
{"owner": "attacker", "description": "pwned"},
|
||||
)
|
||||
except TypeError as e:
|
||||
# If the method were fixed to require an owner arg, this is
|
||||
# the desired (safe) behavior — the call refused.
|
||||
pytest.skip(
|
||||
f"update_skill raised TypeError (refused unsafe call): {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# After: read what each file now contains.
|
||||
after_alice = alice_path.read_text(encoding="utf-8")
|
||||
after_bob = bob_path.read_text(encoding="utf-8")
|
||||
|
||||
# Invariant 1: a file that was owned by `alice` (resp. `bob`) MUST
|
||||
# NOT end up owned by `attacker` after the call. If it does, that's
|
||||
# the cross-user ownership reassignment bug.
|
||||
assert "owner: attacker" not in after_alice, (
|
||||
"BUG: Alice's file was silently re-owned as 'attacker' by "
|
||||
"update_skill (cross-user ownership reassignment)."
|
||||
)
|
||||
assert "owner: attacker" not in after_bob, (
|
||||
"BUG: Bob's file was silently re-owned as 'attacker' by "
|
||||
"update_skill (cross-user ownership reassignment)."
|
||||
)
|
||||
|
||||
# Invariant 2: a file that was owned by `alice` and contained
|
||||
# description "alice original" must not be silently mutated into
|
||||
# "pwned" by a caller that did not supply an owner.
|
||||
if "alice original" in before_alice:
|
||||
assert "alice original" in after_alice, (
|
||||
"BUG: Alice's skill description was overwritten by a call "
|
||||
"to update_skill that did not scope to her owner."
|
||||
)
|
||||
|
||||
if "bob original" in before_bob:
|
||||
assert "bob original" in after_bob, (
|
||||
"BUG: Bob's skill description was overwritten by a call "
|
||||
"to update_skill that did not scope to his owner."
|
||||
)
|
||||
|
||||
# The return value should not lie about success — if the manager
|
||||
# touched nothing because both files were foreign-owned, the safer
|
||||
# behavior is to return False, not True. (A return of True is the
|
||||
# buggy path; we don't assert False, we just don't assert True.)
|
||||
_ = result # not asserted; documented behavior is not the point.
|
||||
|
||||
|
||||
def test_update_skill_scalar_keys_exclude_owner():
|
||||
"""Static check: the manager's scalar_keys whitelist MUST NOT
|
||||
include 'owner' — otherwise a non-owner caller can pass
|
||||
updates={'owner': 'attacker'} and reassign the file. The fix
|
||||
removed 'owner' from scalar_keys; this test now asserts the
|
||||
fix is in place."""
|
||||
src = Path("services/memory/skills.py").read_text(encoding="utf-8")
|
||||
import re
|
||||
m = re.search(
|
||||
r"def update_skill\(.*?scalar_keys\s*=\s*\((.*?)\)",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "could not locate scalar_keys tuple in update_skill"
|
||||
body = m.group(1)
|
||||
assert '"owner"' not in body and "'owner'" not in body, (
|
||||
"BUG (regression): scalar_keys in update_skill includes 'owner'. "
|
||||
"The fix removed this to prevent cross-user ownership reassignment "
|
||||
"via the updates dict."
|
||||
)
|
||||
Reference in New Issue
Block a user