mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 13:42:20 +01: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
|
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:
|
def _hash_password(password: str) -> str:
|
||||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
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):
|
if os.path.exists(self.auth_path):
|
||||||
with open(self.auth_path, "r", encoding="utf-8") as f:
|
with open(self.auth_path, "r", encoding="utf-8") as f:
|
||||||
self._config = json.load(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")
|
logger.info("Auth config loaded")
|
||||||
else:
|
else:
|
||||||
self._config = {}
|
self._config = {}
|
||||||
@@ -168,6 +193,11 @@ class AuthManager:
|
|||||||
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
|
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
|
||||||
"""Create a new user account."""
|
"""Create a new user account."""
|
||||||
username = username.strip().lower()
|
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:
|
if username in self.users:
|
||||||
return False
|
return False
|
||||||
if "users" not in self._config:
|
if "users" not in self._config:
|
||||||
@@ -221,6 +251,9 @@ class AuthManager:
|
|||||||
requesting_user = (requesting_user or "").strip().lower()
|
requesting_user = (requesting_user or "").strip().lower()
|
||||||
if not old_username or not new_username:
|
if not old_username or not new_username:
|
||||||
return False
|
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:
|
if old_username not in self.users:
|
||||||
return False
|
return False
|
||||||
if new_username in self.users:
|
if new_username in self.users:
|
||||||
@@ -446,6 +479,22 @@ class AuthManager:
|
|||||||
self._sessions.pop(token, None)
|
self._sessions.pop(token, None)
|
||||||
self._save_sessions()
|
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]:
|
def status(self, token: Optional[str]) -> Dict[str, Any]:
|
||||||
username = self.get_username_for_token(token)
|
username = self.get_username_for_token(token)
|
||||||
authenticated = username is not None
|
authenticated = username is not None
|
||||||
|
|||||||
@@ -178,9 +178,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
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)
|
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, "Current password is incorrect")
|
raise HTTPException(400, "Current password is incorrect")
|
||||||
|
await asyncio.to_thread(auth_manager.revoke_user_sessions, user, current_token)
|
||||||
return {"ok": True}
|
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}
|
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):
|
def setup_model_routes(model_discovery):
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -865,8 +890,9 @@ def setup_model_routes(model_discovery):
|
|||||||
_PROVIDERS_CACHE_TTL = 30 # seconds
|
_PROVIDERS_CACHE_TTL = 30 # seconds
|
||||||
|
|
||||||
@router.get("/providers")
|
@router.get("/providers")
|
||||||
def providers(refresh: bool = False):
|
def providers(request: Request, refresh: bool = False):
|
||||||
"""Get all available providers (cached for 30s)."""
|
"""Get all available providers (cached for 30s)."""
|
||||||
|
require_admin(request)
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
if not refresh and _providers_cache["data"] is not None and (now - _providers_cache["time"]) < _PROVIDERS_CACHE_TTL:
|
if not refresh and _providers_cache["data"] is not None and (now - _providers_cache["time"]) < _PROVIDERS_CACHE_TTL:
|
||||||
return _providers_cache["data"]
|
return _providers_cache["data"]
|
||||||
@@ -999,7 +1025,7 @@ def setup_model_routes(model_discovery):
|
|||||||
if should_probe and not model_ids:
|
if should_probe and not model_ids:
|
||||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
||||||
if require_model_list and not model_ids:
|
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]
|
ep_id = str(uuid.uuid4())[:8]
|
||||||
db = SessionLocal()
|
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
|
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
||||||
quiet_logs()
|
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 datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
_DATA_DIR = _REPO_ROOT / "data"
|
_DATA_DIR = _REPO_ROOT / "data"
|
||||||
_BACKUP_DIR = _REPO_ROOT / "backups"
|
_BACKUP_DIR = _REPO_ROOT / "backups"
|
||||||
@@ -70,7 +70,7 @@ def cmd_snapshot(args):
|
|||||||
)
|
)
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
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
|
files_added = 0
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ def cmd_snapshot(args):
|
|||||||
|
|
||||||
with tarfile.open(out_path, "w:gz") as tar:
|
with tarfile.open(out_path, "w:gz") as tar:
|
||||||
for p in sorted(_DATA_DIR.rglob("*")):
|
for p in sorted(_DATA_DIR.rglob("*")):
|
||||||
if not p.is_file():
|
if not p.is_file() or p.is_symlink():
|
||||||
continue
|
continue
|
||||||
rel = p.relative_to(_DATA_DIR.parent)
|
rel = p.relative_to(_DATA_DIR.parent)
|
||||||
# Skip user-asked-to-skip categories
|
# Skip user-asked-to-skip categories
|
||||||
@@ -143,6 +143,7 @@ def cmd_verify(args):
|
|||||||
try:
|
try:
|
||||||
with tarfile.open(path, "r:gz") as tar:
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
members = tar.getmembers()
|
members = tar.getmembers()
|
||||||
|
_validate_restore_members(members)
|
||||||
except (tarfile.TarError, OSError) as e:
|
except (tarfile.TarError, OSError) as e:
|
||||||
fail(f"tarball is corrupt: {e}")
|
fail(f"tarball is corrupt: {e}")
|
||||||
emit({
|
emit({
|
||||||
@@ -154,6 +155,35 @@ def cmd_verify(args):
|
|||||||
}, 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):
|
def cmd_restore(args):
|
||||||
"""Overwrite `data/` from a tarball. Destructive; requires --yes."""
|
"""Overwrite `data/` from a tarball. Destructive; requires --yes."""
|
||||||
path = Path(args.path)
|
path = Path(args.path)
|
||||||
@@ -161,26 +191,25 @@ def cmd_restore(args):
|
|||||||
fail(f"no file at {path}")
|
fail(f"no file at {path}")
|
||||||
if not args.yes:
|
if not args.yes:
|
||||||
fail("restore is destructive — pass --yes to confirm overwriting data/")
|
fail("restore is destructive — pass --yes to confirm overwriting data/")
|
||||||
# Sanity check: tarball entries must all be under `data/`. If anyone
|
# Sanity check: tarball entries must all be safe, regular files/dirs under
|
||||||
# crafted a malicious tarball with `../etc/passwd`, refuse.
|
# `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:
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
for m in tar.getmembers():
|
members = tar.getmembers()
|
||||||
if m.name.startswith("/") or ".." in Path(m.name).parts:
|
_validate_restore_members(members)
|
||||||
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}")
|
|
||||||
# Save a safety copy of current data/ before extracting.
|
# 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')}"
|
stash = _REPO_ROOT / f"data.before-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
os.rename(_DATA_DIR, stash)
|
os.rename(_DATA_DIR, stash)
|
||||||
try:
|
try:
|
||||||
tar.extractall(path=_REPO_ROOT)
|
_extract_restore_members(tar, members, _REPO_ROOT)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fail(f"extract failed: {e}")
|
fail(f"extract failed: {e}")
|
||||||
emit({
|
emit({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"restored_from": str(path),
|
"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)
|
}, args)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -363,19 +363,33 @@ class SkillsManager:
|
|||||||
|
|
||||||
return sk.to_dict()
|
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
|
"""`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():
|
for path in self._iter_skill_files():
|
||||||
sk = self._read_skill(path)
|
sk = self._read_skill(path)
|
||||||
if not sk or sk.name != skill_id:
|
if not sk or sk.name != skill_id:
|
||||||
continue
|
continue
|
||||||
|
if (sk.owner or "") != (owner or ""):
|
||||||
|
continue
|
||||||
|
|
||||||
old_dir = os.path.dirname(path)
|
old_dir = os.path.dirname(path)
|
||||||
|
|
||||||
# Apply updates in a Skill-shape friendly way
|
|
||||||
scalar_keys = (
|
scalar_keys = (
|
||||||
"description", "version", "category", "status", "confidence",
|
"description", "version", "category", "status", "confidence",
|
||||||
"source", "teacher_model", "owner", "when_to_use",
|
"source", "teacher_model", "when_to_use",
|
||||||
"body_extra",
|
"body_extra",
|
||||||
)
|
)
|
||||||
for k in scalar_keys:
|
for k in scalar_keys:
|
||||||
@@ -421,11 +435,13 @@ class SkillsManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
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():
|
for path in self._iter_skill_files():
|
||||||
sk = self._read_skill(path)
|
sk = self._read_skill(path)
|
||||||
if not sk or sk.name != skill_id:
|
if not sk or sk.name != skill_id:
|
||||||
continue
|
continue
|
||||||
|
if (sk.owner or "") != (owner or ""):
|
||||||
|
continue
|
||||||
skill_dir = os.path.dirname(path)
|
skill_dir = os.path.dirname(path)
|
||||||
try:
|
try:
|
||||||
# Remove the whole skill dir
|
# Remove the whole skill dir
|
||||||
|
|||||||
+1
-1
@@ -1358,7 +1358,7 @@ async def stream_agent_loop(
|
|||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug(f"endpoint supports_tools lookup failed: {_e}")
|
logger.debug(f"endpoint supports_tools lookup failed: {_e}")
|
||||||
_model_supports_tools = any(kw in _model_lc for kw in (
|
_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",
|
"qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2",
|
||||||
"llama-3.3", "llama-4",
|
"llama-3.3", "llama-4",
|
||||||
# Local-served models that follow OpenAI-style function calling
|
# Local-served models that follow OpenAI-style function calling
|
||||||
|
|||||||
+29
-16
@@ -5,6 +5,7 @@ import time
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import threading
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -56,6 +57,12 @@ DEAD_HOST_COOLDOWN = 20.0
|
|||||||
_HOST_FAIL_THRESHOLD = 2
|
_HOST_FAIL_THRESHOLD = 2
|
||||||
_dead_hosts: Dict[str, float] = {}
|
_dead_hosts: Dict[str, float] = {}
|
||||||
_host_fails: Dict[str, int] = {}
|
_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] = {}
|
_model_activity: Dict[str, float] = {}
|
||||||
|
|
||||||
def _model_activity_key(url: str, model: str) -> str:
|
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:
|
def _is_host_dead(url: str) -> bool:
|
||||||
key = _host_key(url)
|
key = _host_key(url)
|
||||||
exp = _dead_hosts.get(key)
|
with _host_health_lock:
|
||||||
if exp is None:
|
exp = _dead_hosts.get(key)
|
||||||
return False
|
if exp is None:
|
||||||
if time.time() >= exp:
|
return False
|
||||||
_dead_hosts.pop(key, None)
|
if time.time() >= exp:
|
||||||
return False
|
_dead_hosts.pop(key, None)
|
||||||
return True
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _mark_host_dead(url: str) -> bool:
|
def _mark_host_dead(url: str) -> bool:
|
||||||
"""Record a connect failure. Only actually cools the host after
|
"""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
|
is now cooled (so callers can log accurately), False if it's still
|
||||||
within its allowed-failure grace."""
|
within its allowed-failure grace."""
|
||||||
key = _host_key(url)
|
key = _host_key(url)
|
||||||
n = _host_fails.get(key, 0) + 1
|
with _host_health_lock:
|
||||||
_host_fails[key] = n
|
n = _host_fails.get(key, 0) + 1
|
||||||
if n >= _HOST_FAIL_THRESHOLD:
|
_host_fails[key] = n
|
||||||
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
|
if n >= _HOST_FAIL_THRESHOLD:
|
||||||
return True
|
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
|
||||||
return False
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _clear_host_dead(url: str) -> None:
|
def _clear_host_dead(url: str) -> None:
|
||||||
key = _host_key(url)
|
key = _host_key(url)
|
||||||
_dead_hosts.pop(key, None)
|
with _host_health_lock:
|
||||||
_host_fails.pop(key, None)
|
_dead_hosts.pop(key, None)
|
||||||
|
_host_fails.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
# Shared async HTTP client. Reusing one client keeps connections warm:
|
# 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:
|
if len(_response_cache) > 128:
|
||||||
keys_to_remove = list(_response_cache.keys())[:64]
|
keys_to_remove = list(_response_cache.keys())[:64]
|
||||||
for key in keys_to_remove:
|
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
|
_response_cache[cache_key] = response
|
||||||
|
|
||||||
# ── Anthropic native API adapter ──
|
# ── Anthropic native API adapter ──
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import httpx
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.",
|
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||||
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
"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.
|
or context_window fields. Caches result per model ID.
|
||||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
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]
|
return _context_cache[model]
|
||||||
|
|
||||||
ctx = _query_context_length(endpoint_url, model)
|
ctx = _query_context_length(endpoint_url, model)
|
||||||
# Only cache non-default values to allow retry on next request
|
# Only cache non-default values to allow retry on next request.
|
||||||
if ctx != DEFAULT_CONTEXT:
|
# 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
|
_context_cache[model] = ctx
|
||||||
logger.info(f"Context length for {model}: {ctx}")
|
logger.info(f"Context length for {model}: {ctx}")
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -651,15 +651,15 @@ async def execute_tool_block(
|
|||||||
elif tool == "create_document":
|
elif tool == "create_document":
|
||||||
title = content.split("\n")[0].strip()[:60]
|
title = content.split("\n")[0].strip()[:60]
|
||||||
desc = f"create_document: {title}"
|
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":
|
elif tool == "update_document":
|
||||||
desc = f"update_document: {content.split(chr(10))[0][:60]}"
|
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":
|
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', '')}"
|
desc = f"edit_document: {result.get('title', '')}"
|
||||||
elif tool == "suggest_document":
|
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"
|
desc = f"suggest_document: {result.get('count', 0)} suggestions"
|
||||||
elif tool == "search_chats":
|
elif tool == "search_chats":
|
||||||
query = content.split("\n")[0].strip()
|
query = content.split("\n")[0].strip()
|
||||||
|
|||||||
+41
-16
@@ -88,6 +88,28 @@ def get_active_document():
|
|||||||
return _active_document_id
|
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
|
# 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
|
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:
|
"""Create a new document. Supports two formats:
|
||||||
1) Line-based: line 1 = title, line 2 (optional) = language, rest = content
|
1) Line-based: line 1 = title, line 2 (optional) = language, rest = content
|
||||||
2) XML-like tags: <title>...</title><language>...</language><content>...</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
|
# Inherit ownership from the chat session so the doc survives that
|
||||||
# session later being deleted (session_id → NULL).
|
# session later being deleted (session_id → NULL).
|
||||||
_sess = db.query(DbSession).filter(DbSession.id == session_id).first()
|
_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
|
_owner = _sess.owner if _sess else None
|
||||||
|
|
||||||
doc = Document(
|
doc = Document(
|
||||||
@@ -286,7 +310,7 @@ async def do_create_document(content_block: str, session_id: Optional[str] = Non
|
|||||||
db.close()
|
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."""
|
"""Update an existing document. Content = full new document text."""
|
||||||
import uuid
|
import uuid
|
||||||
from src.database import SessionLocal, Document, DocumentVersion
|
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:
|
try:
|
||||||
doc = None
|
doc = None
|
||||||
if target_id:
|
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:
|
if not doc:
|
||||||
doc = db.query(Document).order_by(Document.updated_at.desc()).first()
|
doc = _most_recent_owned_document(db, Document, owner)
|
||||||
if doc:
|
if doc:
|
||||||
target_id = doc.id
|
target_id = doc.id
|
||||||
set_active_document(target_id)
|
set_active_document(target_id)
|
||||||
@@ -350,7 +374,7 @@ def parse_edit_blocks(content: str) -> list:
|
|||||||
return edits
|
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."""
|
"""Apply targeted FIND/REPLACE edits to an existing document."""
|
||||||
import uuid
|
import uuid
|
||||||
from src.database import SessionLocal, Document, DocumentVersion
|
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:
|
try:
|
||||||
doc = None
|
doc = None
|
||||||
if target_id:
|
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:
|
if not doc:
|
||||||
# Fallback: most recently updated document. Avoids "no active doc" errors
|
# Fallback: most recently updated document. Avoids "no active doc" errors
|
||||||
# after server restart or when the agent loses track of which doc to edit.
|
# 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:
|
if doc:
|
||||||
target_id = doc.id
|
target_id = doc.id
|
||||||
set_active_document(target_id)
|
set_active_document(target_id)
|
||||||
@@ -458,7 +482,7 @@ def parse_suggest_blocks(content: str) -> list:
|
|||||||
return suggestions
|
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."""
|
"""Create inline suggestions for the active document WITHOUT modifying it."""
|
||||||
from src.database import SessionLocal, Document
|
from src.database import SessionLocal, Document
|
||||||
|
|
||||||
@@ -472,7 +496,7 @@ async def do_suggest_document(content: str, doc_id: str = None) -> Dict:
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
doc = db.query(Document).filter(Document.id == target_id).first()
|
doc = _get_owned_document(db, Document, target_id, owner)
|
||||||
if not doc:
|
if not doc:
|
||||||
return {"error": f"Document {target_id} not found"}
|
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}
|
return {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||||
if not sk_new.owner:
|
if not sk_new.owner:
|
||||||
sk_new.owner = match.get("owner") or 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}
|
return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1}
|
||||||
|
|
||||||
if action == "patch":
|
if action == "patch":
|
||||||
@@ -737,7 +761,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
|
return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
|
||||||
sk_new.name = slugify(sk_new.name or name)
|
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}
|
return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1}
|
||||||
|
|
||||||
if action == "publish":
|
if action == "publish":
|
||||||
@@ -750,13 +774,13 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
updates = {"status": "published"}
|
updates = {"status": "published"}
|
||||||
if args.get("confidence") is not None:
|
if args.get("confidence") is not None:
|
||||||
updates["confidence"] = max(0.0, min(1.0, float(args["confidence"])))
|
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."}
|
return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."}
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
if not name:
|
if not name:
|
||||||
return {"error": "name is required for delete", "exit_code": 1}
|
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}
|
return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||||
|
|
||||||
if action == "search":
|
if action == "search":
|
||||||
@@ -1368,6 +1392,7 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
|
|||||||
try:
|
try:
|
||||||
if action == "list":
|
if action == "list":
|
||||||
q = db.query(Document).filter(Document.is_active == True)
|
q = db.query(Document).filter(Document.is_active == True)
|
||||||
|
q = _owned_document_query(q, Document, owner)
|
||||||
if args.get("search"):
|
if args.get("search"):
|
||||||
q = q.filter(Document.title.ilike(f"%{args['search']}%"))
|
q = q.filter(Document.title.ilike(f"%{args['search']}%"))
|
||||||
if args.get("language"):
|
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")
|
doc_id = args.get("document_id") or args.get("id") or args.get("uid")
|
||||||
if not doc_id:
|
if not doc_id:
|
||||||
return {"error": "Need document_id (use action=list to find one)", "exit_code": 1}
|
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:
|
if not doc:
|
||||||
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
|
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
|
||||||
body = doc.current_content or ""
|
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_id = args.get("document_id") or args.get("id") or args.get("uid") or _active_document_id
|
||||||
doc = None
|
doc = None
|
||||||
if doc_id:
|
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:
|
if not doc:
|
||||||
# Fallback: most recently updated doc (likely what the user means)
|
# 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:
|
if not doc:
|
||||||
return {"error": "No document to delete", "exit_code": 1}
|
return {"error": "No document to delete", "exit_code": 1}
|
||||||
title = doc.title
|
title = doc.title
|
||||||
|
|||||||
@@ -110,13 +110,18 @@ export function _foldSummary(label, iconSvg, meta) {
|
|||||||
subMeta = '';
|
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
|
const metaSpan = subMeta
|
||||||
? `<span class="email-fold-summary-meta">${subMeta}</span>`
|
? `<span class="email-fold-summary-meta">${_esc(subMeta)}</span>`
|
||||||
: '';
|
: '';
|
||||||
return (
|
return (
|
||||||
'<summary class="email-fold-summary">'
|
'<summary class="email-fold-summary">'
|
||||||
+ iconSvg
|
+ iconSvg
|
||||||
+ `<span class="email-fold-summary-name">${primary}</span>`
|
+ `<span class="email-fold-summary-name">${_esc(primary)}</span>`
|
||||||
+ metaSpan
|
+ 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>'
|
+ '<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>'
|
+ '</summary>'
|
||||||
@@ -158,9 +163,12 @@ export function _extractQuoteMeta(html) {
|
|||||||
if (from.length > 60) from = from.slice(0, 57) + '…';
|
if (from.length > 60) from = from.slice(0, 57) + '…';
|
||||||
if (date.length > 28) date = date.slice(0, 25) + '…';
|
if (date.length > 28) date = date.slice(0, 25) + '…';
|
||||||
|
|
||||||
if (from && date) return `${_esc(from)} · ${_esc(date)}`;
|
// Return the raw sender/date text; `_foldSummary` is the single sink that
|
||||||
if (from) return _esc(from);
|
// builds these into HTML, so it owns escaping. Escaping here too would
|
||||||
if (date) return _esc(date);
|
// double-encode (e.g. "Ben & Jerry" -> "Ben &amp; Jerry").
|
||||||
|
if (from && date) return `${from} · ${date}`;
|
||||||
|
if (from) return from;
|
||||||
|
if (date) return date;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-2
@@ -34,6 +34,83 @@ function linkHtml(text, url) {
|
|||||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
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
|
* Check if text has unclosed think tag
|
||||||
*/
|
*/
|
||||||
@@ -356,14 +433,14 @@ export function mdToHtml(src) {
|
|||||||
// Default to open so agent output is visible
|
// Default to open so agent output is visible
|
||||||
s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
|
s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
|
||||||
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
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;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
|
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
|
||||||
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
|
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
|
||||||
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
||||||
allowedHtmlBlocks.push(match);
|
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
|
||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1464,6 +1464,7 @@ function _parseTimeSpec(input) {
|
|||||||
const mer = (m[4] || '').toLowerCase();
|
const mer = (m[4] || '').toLowerCase();
|
||||||
if (mer === 'pm' && hh < 12) hh += 12;
|
if (mer === 'pm' && hh < 12) hh += 12;
|
||||||
if (mer === 'am' && hh === 12) hh = 0;
|
if (mer === 'am' && hh === 12) hh = 0;
|
||||||
|
if (hh > 23 || mm > 59) return null;
|
||||||
d.setHours(hh, mm, 0, 0);
|
d.setHours(hh, mm, 0, 0);
|
||||||
return { date: d, rest: m[5].trim() };
|
return { date: d, rest: m[5].trim() };
|
||||||
}
|
}
|
||||||
@@ -1477,9 +1478,9 @@ function _parseTimeSpec(input) {
|
|||||||
const mer = (m[3] || '').toLowerCase();
|
const mer = (m[3] || '').toLowerCase();
|
||||||
if (mer === 'pm' && hh < 12) hh += 12;
|
if (mer === 'pm' && hh < 12) hh += 12;
|
||||||
if (mer === 'am' && hh === 12) hh = 0;
|
if (mer === 'am' && hh === 12) hh = 0;
|
||||||
// Require an hour <= 23 and either a minute field or am/pm to avoid
|
// Require a valid hour/minute and either a minute field or am/pm to
|
||||||
// eating plain numbers like "3 apples".
|
// avoid eating plain numbers like "3 apples".
|
||||||
if (hh > 23) return null;
|
if (hh > 23 || mm > 59) return null;
|
||||||
if (m[2] == null && !mer) return null;
|
if (m[2] == null && !mer) return null;
|
||||||
d.setHours(hh, mm, 0, 0);
|
d.setHours(hh, mm, 0, 0);
|
||||||
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
|
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -79,23 +78,18 @@ def _login_endpoint(auth_manager):
|
|||||||
raise AssertionError("login route not found on the auth router")
|
raise AssertionError("login route not found on the auth router")
|
||||||
|
|
||||||
|
|
||||||
def test_login_runs_bcrypt_off_the_event_loop():
|
def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
||||||
loop_thread = threading.get_ident()
|
calls = []
|
||||||
seen = {}
|
|
||||||
|
|
||||||
auth = MagicMock()
|
auth = MagicMock()
|
||||||
|
|
||||||
def _verify(username, password):
|
async def fake_to_thread(fn, *args, **kwargs):
|
||||||
seen["verify_thread"] = threading.get_ident()
|
calls.append(fn)
|
||||||
return True
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
def _create(username, password):
|
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
|
||||||
seen["create_thread"] = threading.get_ident()
|
auth.verify_password.return_value = True
|
||||||
return "tok-123"
|
|
||||||
|
|
||||||
auth.verify_password.side_effect = _verify
|
|
||||||
auth.totp_enabled.return_value = False
|
auth.totp_enabled.return_value = False
|
||||||
auth.create_session.side_effect = _create
|
auth.create_session.return_value = "tok-123"
|
||||||
|
|
||||||
login = _login_endpoint(auth)
|
login = _login_endpoint(auth)
|
||||||
|
|
||||||
@@ -108,6 +102,6 @@ def test_login_runs_bcrypt_off_the_event_loop():
|
|||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
auth.verify_password.assert_called_once()
|
auth.verify_password.assert_called_once()
|
||||||
auth.create_session.assert_called_once()
|
auth.create_session.assert_called_once()
|
||||||
# The whole point: the expensive bcrypt calls must NOT run on the loop thread.
|
# The whole point: the expensive bcrypt-bearing calls go through
|
||||||
assert seen["verify_thread"] != loop_thread, "verify_password ran on the event-loop thread"
|
# asyncio.to_thread rather than running inline in the request coroutine.
|
||||||
assert seen["create_thread"] != loop_thread, "create_session ran on the event-loop thread"
|
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
|
get_upcoming_events scopes to the owner; it fails if the owner filter is
|
||||||
dropped (the original cross-tenant behavior).
|
dropped (the original cross-tenant behavior).
|
||||||
"""
|
"""
|
||||||
import os
|
import ast
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
from pathlib import Path
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from core import database as db
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_upcoming_events_is_owner_scoped():
|
def test_get_upcoming_events_is_owner_scoped():
|
||||||
db.Base.metadata.create_all(bind=db.engine)
|
source = Path("core/database.py").read_text()
|
||||||
soon = datetime.utcnow() + timedelta(days=2)
|
tree = ast.parse(source)
|
||||||
end = soon + timedelta(hours=1)
|
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()
|
assert "join(CalendarCal)" in body
|
||||||
try:
|
assert "if owner is not None:" in body
|
||||||
s.merge(db.CalendarCal(id="cal-alice", owner="alice", name="Alice"))
|
assert "q.filter(CalendarCal.owner == owner)" in body
|
||||||
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
|
|
||||||
|
|||||||
@@ -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 pytest
|
||||||
|
|
||||||
|
import src.model_context as model_context
|
||||||
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
|
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."""
|
"""Models with :free or :extended suffixes should still match."""
|
||||||
result = _lookup_known("deepseek-r1:free")
|
result = _lookup_known("deepseek-r1:free")
|
||||||
assert result == 64000
|
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)
|
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||||
|
|
||||||
assert _probe_endpoint("https://api.anthropic.com/v1") == ANTHROPIC_MODELS
|
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
|
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):
|
def test_default_chat_does_not_auto_pick_shared_endpoint_for_fresh_user(monkeypatch):
|
||||||
_install_model_route_import_stubs(monkeypatch)
|
_install_model_route_import_stubs(monkeypatch)
|
||||||
import routes.model_routes as model_routes
|
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