/cmdline; stdin also avoids leaving the secret in
- # the child process environment.
- stdout, stderr, rc = await _run_bw(
- ["unlock", "--raw"],
- input_text=req.master_password + "\n",
- )
- if rc != 0:
- return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
- session = stdout.strip()
- if not session:
- return {"ok": False, "error": "bw returned empty session"}
- cfg = _load_config()
- cfg["session"] = session
- cfg["unlocked_at"] = datetime.utcnow().isoformat()
- _save_config(cfg)
- return {"ok": True, "message": "Vault unlocked"}
-
- @router.post("/lock")
- async def lock(request: Request):
- """Lock the vault (clear session from config)."""
- require_admin(request)
- cfg = _load_config()
- cfg.pop("session", None)
- cfg.pop("unlocked_at", None)
- _save_config(cfg)
- # Also tell bw to lock
- await _run_bw(["lock"])
- return {"ok": True, "message": "Vault locked"}
-
- @router.post("/logout")
- async def logout(request: Request):
- """Log out of the Bitwarden CLI completely."""
- require_admin(request)
- await _run_bw(["logout"])
- cfg = _load_config()
- cfg.pop("session", None)
- cfg.pop("email", None)
- cfg.pop("unlocked_at", None)
- _save_config(cfg)
- return {"ok": True}
-
- return router
-
-
-async def _check_bw_installed() -> bool:
- try:
- proc = await asyncio.create_subprocess_exec(
- _find_bw(), "--version",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- await proc.communicate()
- return proc.returncode == 0
- except Exception:
- return False
+_sys.modules[__name__] = _canonical
diff --git a/routes/webhook/__init__.py b/routes/webhook/__init__.py
new file mode 100644
index 000000000..e51389e3a
--- /dev/null
+++ b/routes/webhook/__init__.py
@@ -0,0 +1,5 @@
+"""Webhook route domain package (slice 2l, #4082/#4071).
+
+Contains webhook_routes.py, migrated from the flat routes/ directory.
+Backward-compat shim at routes/webhook_routes.py re-exports from here.
+"""
diff --git a/routes/webhook/webhook_routes.py b/routes/webhook/webhook_routes.py
new file mode 100644
index 000000000..8d3a704c6
--- /dev/null
+++ b/routes/webhook/webhook_routes.py
@@ -0,0 +1,395 @@
+"""Webhook, API Token, and sync chat routes."""
+
+import uuid
+import logging
+from typing import Optional
+
+import httpx
+from fastapi import APIRouter, HTTPException, Request, Form
+from pydantic import BaseModel, Field
+
+from core.database import SessionLocal, Webhook, ModelEndpoint
+from src.auth_helpers import owner_filter
+from src.url_security import validate_public_http_url
+from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["webhooks"])
+
+# Input limits
+MAX_NAME_LEN = 100
+MAX_URL_LEN = 2048
+MAX_SECRET_LEN = 256
+MAX_MESSAGE_LEN = 32_000
+
+
+from core.middleware import require_admin as _require_admin
+
+
+def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
+ """First enabled ModelEndpoint visible to token_owner — their own rows plus
+ legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
+ let a chat-scoped token fall back onto another user's private endpoint and
+ silently spend that owner's API key/quota. Prefer owner rows before shared
+ rows. Fails closed to null-owner rows only when token_owner is absent.
+ Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
+ """
+ query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
+ if token_owner:
+ query = owner_filter(query, ModelEndpoint, token_owner)
+ return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
+ return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
+
+
+def _caller_owns_session(sess_owner, caller) -> bool:
+ """Strict session-ownership gate for the token-authenticated sync-chat
+ endpoint (`POST /api/v1/chat`).
+
+ Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
+ gates in notes/calendar/gallery: a caller may resume a session ONLY when
+ its owner matches them exactly. A null/empty session owner (legacy or
+ migrated rows) is deliberately NOT resumable by an arbitrary token — the
+ old ``sess_owner and sess_owner != caller`` form skipped the check whenever
+ ``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
+ device) could resume such a session, inject a message, and read back its
+ history and reuse the owner's endpoint credentials. Fail closed: an
+ unresolvable caller also returns False.
+ """
+ if not caller:
+ return False
+ return sess_owner == caller
+
+
+def setup_webhook_routes(
+ webhook_manager: WebhookManager,
+ auth_manager,
+ session_manager=None,
+ api_key_manager=None,
+) -> APIRouter:
+
+ @router.get("/webhooks")
+ def list_webhooks(request: Request):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ hooks = db.query(Webhook).all()
+ return [
+ {
+ "id": w.id,
+ "name": w.name,
+ "url": w.url,
+ "has_secret": bool(w.secret),
+ "events": w.events.split(",") if w.events else [],
+ "is_active": w.is_active,
+ "last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
+ "last_status_code": w.last_status_code,
+ "last_error": w.last_error,
+ "created_at": w.created_at.isoformat() if w.created_at else None,
+ }
+ for w in hooks
+ ]
+ finally:
+ db.close()
+
+ @router.post("/webhooks")
+ def create_webhook(
+ request: Request,
+ name: str = Form(""),
+ url: str = Form(""),
+ secret: str = Form(""),
+ events: str = Form(""),
+ ):
+ _require_admin(request)
+ name = name.strip()[:MAX_NAME_LEN]
+ if not name:
+ raise HTTPException(400, "Webhook name is required")
+ try:
+ url = validate_webhook_url(url)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+ try:
+ events = validate_events(events)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+
+ secret_val = secret.strip()[:MAX_SECRET_LEN] or None
+ # Encrypt the secret at rest using the same Fernet key as API keys
+ encrypted_secret = None
+ if secret_val and api_key_manager:
+ encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
+ elif secret_val:
+ encrypted_secret = secret_val # Fallback if no encryption available
+
+ webhook_id = str(uuid.uuid4())[:8]
+ db = SessionLocal()
+ try:
+ db.add(Webhook(
+ id=webhook_id,
+ name=name,
+ url=url,
+ secret=encrypted_secret,
+ events=events,
+ is_active=True,
+ ))
+ db.commit()
+ finally:
+ db.close()
+
+ return {"id": webhook_id, "name": name}
+
+ @router.post("/webhooks/{webhook_id}/test")
+ async def test_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
+ if not wh:
+ raise HTTPException(404, "Webhook not found")
+ url, secret = wh.url, wh.secret
+ finally:
+ db.close()
+
+ await webhook_manager.deliver_test(webhook_id, url, secret)
+ return {"status": "sent"}
+
+ @router.patch("/webhooks/{webhook_id}")
+ def toggle_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
+ if not wh:
+ raise HTTPException(404, "Webhook not found")
+ wh.is_active = not wh.is_active
+ db.commit()
+ return {"id": webhook_id, "is_active": wh.is_active}
+ finally:
+ db.close()
+
+ @router.delete("/webhooks/{webhook_id}")
+ def delete_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
+ db.commit()
+ if not deleted:
+ raise HTTPException(404, "Webhook not found")
+ finally:
+ db.close()
+ return {"status": "deleted"}
+
+ # ================================================================
+ # Sync Chat Endpoint (for n8n / Make / Activepieces)
+ # ================================================================
+
+ # Known provider base URLs — auto-resolved from api_key prefix or model name
+ KNOWN_PROVIDERS = {
+ "deepseek": "https://api.deepseek.com/v1",
+ "openai": "https://api.openai.com/v1",
+ "mistral": "https://api.mistral.ai/v1",
+ "groq": "https://api.groq.com/openai/v1",
+ "together": "https://api.together.xyz/v1",
+ "openrouter": "https://openrouter.ai/api/v1",
+ "ollama": "https://ollama.com/api",
+ "opencode-zen": "https://opencode.ai/zen/v1",
+ "opencode-go": "https://opencode.ai/zen/go/v1",
+ "fireworks": "https://api.fireworks.ai/inference/v1",
+ "venice": "https://api.venice.ai/api/v1",
+ "kimi-code": "https://api.kimi.com/coding/v1",
+ "kimicode": "https://api.kimi.com/coding/v1",
+ }
+
+ # Model prefix → provider mapping for auto-detection
+ MODEL_PROVIDER_MAP = {
+ "deepseek": "deepseek",
+ "gpt-": "openai",
+ "o1": "openai",
+ "o3": "openai",
+ "o4": "openai",
+ "mistral": "mistral",
+ "llama": "groq",
+ "mixtral": "groq",
+ "kimi-for-coding": "kimi-code",
+ "kimi": "kimi-code",
+ }
+
+ def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
+ """Try to auto-resolve a base URL from provider name or model prefix."""
+ if provider and provider.lower() in KNOWN_PROVIDERS:
+ return KNOWN_PROVIDERS[provider.lower()]
+ if model:
+ model_lower = model.lower()
+ for prefix, prov in MODEL_PROVIDER_MAP.items():
+ if model_lower.startswith(prefix):
+ return KNOWN_PROVIDERS[prov]
+ return None
+
+ class SyncChatRequest(BaseModel):
+ message: str = Field(..., max_length=MAX_MESSAGE_LEN)
+ model: Optional[str] = Field(None, max_length=200)
+ session: Optional[str] = Field(None, max_length=100)
+ api_key: Optional[str] = Field(None, max_length=256)
+ base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
+ provider: Optional[str] = Field(None, max_length=50)
+
+ @router.post("/v1/chat")
+ async def sync_chat(request: Request, body: SyncChatRequest):
+ if not getattr(request.state, "api_token", False):
+ raise HTTPException(403, "This endpoint requires an API token")
+ scopes = set(getattr(request.state, "api_token_scopes", []) or [])
+ if "chat" not in scopes:
+ raise HTTPException(403, "API token is not scoped for chat")
+ token_owner = getattr(request.state, "api_token_owner", None)
+
+ from core.models import ChatMessage
+ from src.llm_core import llm_call_async
+ from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
+
+ message = body.message.strip()
+ if not message:
+ raise HTTPException(400, "Message is required")
+
+ session_id = body.session
+ sess = None
+
+ # --- Case 1: Resume an existing session ---
+ if session_id and session_manager:
+ try:
+ sess = session_manager.get_session(session_id)
+ except (KeyError, Exception):
+ raise HTTPException(404, "Session not found")
+ # SECURITY: verify the API-token's user owns this session — without
+ # this any token holder could resume any user's chat by passing its
+ # ID. The token's user is on request.state.user (set by API-token
+ # middleware); fall back to require_user if not present.
+ try:
+ from src.auth_helpers import get_current_user as _gcu
+ _tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
+ except Exception:
+ _tok_user = None
+ # Strict ownership (see _caller_owns_session): fail closed so a
+ # null-owner / cross-owner session can't be resumed by an arbitrary
+ # chat-scoped token.
+ _sess_owner = getattr(sess, "owner", None)
+ if not _caller_owns_session(_sess_owner, _tok_user):
+ raise HTTPException(404, "Session not found")
+
+ # --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
+ if not sess and body.api_key:
+ api_key = body.api_key.strip()
+ model = body.model or "deepseek-chat"
+
+ # Validate only token-supplied direct base_url; auto-resolved known-provider
+ # URLs are not subject to extra local/LAN blocking beyond existing provider logic.
+ direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
+ if direct_base_url:
+ try:
+ base_url = validate_public_http_url(direct_base_url)
+ except ValueError as e:
+ detail = str(e).replace("URL", "base_url", 1)
+ raise HTTPException(400, detail)
+ else:
+ base_url = _resolve_base_url(model, body.provider)
+ if not base_url:
+ raise HTTPException(400,
+ "Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
+ "or provider ('deepseek', 'openai', 'groq', etc.)")
+ base_url = normalize_base(base_url)
+ endpoint_url = build_chat_url(base_url)
+
+ if not session_manager:
+ raise HTTPException(500, "Session manager not available")
+
+ sid = str(uuid.uuid4())
+ sess = session_manager.create_session(
+ session_id=sid, name="API Chat", endpoint_url=endpoint_url,
+ model=model, owner=token_owner,
+ )
+ sess.headers = build_headers(api_key, base_url)
+ session_manager.save_sessions()
+ session_id = sid
+
+ # --- Case 3: Fall back to first configured ModelEndpoint ---
+ if not sess:
+ db = SessionLocal()
+ try:
+ ep = _select_api_chat_fallback_endpoint(db, token_owner)
+ finally:
+ db.close()
+
+ if not ep:
+ raise HTTPException(400,
+ "No session, api_key, or configured endpoints. "
+ "Pass api_key + model, or configure an endpoint in Admin.")
+
+ base_url = normalize_base(ep.base_url)
+ endpoint_url = build_chat_url(base_url)
+ model = body.model or "auto"
+ api_key = ep.api_key
+ if getattr(ep, "provider_auth_id", None):
+ try:
+ from src.endpoint_resolver import resolve_endpoint_runtime
+ base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
+ endpoint_url = build_chat_url(base_url)
+ except Exception:
+ raise HTTPException(500, "Could not resolve endpoint credentials")
+
+ if model == "auto":
+ try:
+ async with httpx.AsyncClient(timeout=5) as client:
+ models_url = build_models_url(base_url)
+ hdrs = build_headers(api_key, base_url)
+ if models_url:
+ resp = await client.get(models_url, headers=hdrs)
+ resp.raise_for_status()
+ data = resp.json()
+ items = data if isinstance(data, list) else (data.get("data") or [])
+ ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
+ if not ids and isinstance(data, dict):
+ ids = [
+ m.get("name") or m.get("model")
+ for m in (data.get("models") or [])
+ if m.get("name") or m.get("model")
+ ]
+ else:
+ import json as _json
+ ids = _json.loads(ep.cached_models or "[]")
+ model = ids[0] if ids else "auto"
+ except Exception:
+ raise HTTPException(500, "Could not discover models from endpoint")
+
+ if not session_manager:
+ raise HTTPException(500, "Session manager not available")
+
+ sid = str(uuid.uuid4())
+ sess = session_manager.create_session(
+ session_id=sid, name="API Chat", endpoint_url=endpoint_url,
+ model=model, owner=token_owner,
+ )
+ if api_key:
+ sess.headers = build_headers(api_key, base_url)
+ session_manager.save_sessions()
+ session_id = sid
+
+ # --- Send message and get response ---
+ sess.add_message(ChatMessage("user", message))
+
+ messages = [{"role": m.role, "content": m.content} for m in sess.history]
+
+ reply = await llm_call_async(
+ sess.endpoint_url, sess.model, messages,
+ headers=sess.headers, timeout=120,
+ )
+ sess.add_message(ChatMessage("assistant", reply))
+ session_manager.save_sessions()
+
+ webhook_manager.fire_and_forget("chat.completed", {
+ "session_id": session_id, "model": sess.model,
+ "user_message": message[:2000], "response": reply[:2000],
+ })
+
+ return {"response": reply, "session_id": session_id, "model": sess.model}
+
+ return router
diff --git a/routes/webhook_routes.py b/routes/webhook_routes.py
index 8d3a704c6..7c5e0453e 100644
--- a/routes/webhook_routes.py
+++ b/routes/webhook_routes.py
@@ -1,395 +1,16 @@
-"""Webhook, API Token, and sync chat routes."""
+"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
-import uuid
-import logging
-from typing import Optional
+This module is replaced in ``sys.modules`` by the canonical module object so
+that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
+``importlib.import_module("routes.webhook_routes")``, and the
+``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
+...)`` pattern used by test_null_owner_gates.py all operate on the *same*
+object. Keeps existing import paths working after slice 2l (#4082/#4071).
+Source-introspection tests read the canonical file by path.
+"""
-import httpx
-from fastapi import APIRouter, HTTPException, Request, Form
-from pydantic import BaseModel, Field
+import sys as _sys
-from core.database import SessionLocal, Webhook, ModelEndpoint
-from src.auth_helpers import owner_filter
-from src.url_security import validate_public_http_url
-from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
+from routes.webhook import webhook_routes as _canonical # noqa: F401
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/api", tags=["webhooks"])
-
-# Input limits
-MAX_NAME_LEN = 100
-MAX_URL_LEN = 2048
-MAX_SECRET_LEN = 256
-MAX_MESSAGE_LEN = 32_000
-
-
-from core.middleware import require_admin as _require_admin
-
-
-def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
- """First enabled ModelEndpoint visible to token_owner — their own rows plus
- legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
- let a chat-scoped token fall back onto another user's private endpoint and
- silently spend that owner's API key/quota. Prefer owner rows before shared
- rows. Fails closed to null-owner rows only when token_owner is absent.
- Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
- """
- query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
- if token_owner:
- query = owner_filter(query, ModelEndpoint, token_owner)
- return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
- return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
-
-
-def _caller_owns_session(sess_owner, caller) -> bool:
- """Strict session-ownership gate for the token-authenticated sync-chat
- endpoint (`POST /api/v1/chat`).
-
- Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
- gates in notes/calendar/gallery: a caller may resume a session ONLY when
- its owner matches them exactly. A null/empty session owner (legacy or
- migrated rows) is deliberately NOT resumable by an arbitrary token — the
- old ``sess_owner and sess_owner != caller`` form skipped the check whenever
- ``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
- device) could resume such a session, inject a message, and read back its
- history and reuse the owner's endpoint credentials. Fail closed: an
- unresolvable caller also returns False.
- """
- if not caller:
- return False
- return sess_owner == caller
-
-
-def setup_webhook_routes(
- webhook_manager: WebhookManager,
- auth_manager,
- session_manager=None,
- api_key_manager=None,
-) -> APIRouter:
-
- @router.get("/webhooks")
- def list_webhooks(request: Request):
- _require_admin(request)
- db = SessionLocal()
- try:
- hooks = db.query(Webhook).all()
- return [
- {
- "id": w.id,
- "name": w.name,
- "url": w.url,
- "has_secret": bool(w.secret),
- "events": w.events.split(",") if w.events else [],
- "is_active": w.is_active,
- "last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
- "last_status_code": w.last_status_code,
- "last_error": w.last_error,
- "created_at": w.created_at.isoformat() if w.created_at else None,
- }
- for w in hooks
- ]
- finally:
- db.close()
-
- @router.post("/webhooks")
- def create_webhook(
- request: Request,
- name: str = Form(""),
- url: str = Form(""),
- secret: str = Form(""),
- events: str = Form(""),
- ):
- _require_admin(request)
- name = name.strip()[:MAX_NAME_LEN]
- if not name:
- raise HTTPException(400, "Webhook name is required")
- try:
- url = validate_webhook_url(url)
- except ValueError as e:
- raise HTTPException(400, str(e))
- try:
- events = validate_events(events)
- except ValueError as e:
- raise HTTPException(400, str(e))
-
- secret_val = secret.strip()[:MAX_SECRET_LEN] or None
- # Encrypt the secret at rest using the same Fernet key as API keys
- encrypted_secret = None
- if secret_val and api_key_manager:
- encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
- elif secret_val:
- encrypted_secret = secret_val # Fallback if no encryption available
-
- webhook_id = str(uuid.uuid4())[:8]
- db = SessionLocal()
- try:
- db.add(Webhook(
- id=webhook_id,
- name=name,
- url=url,
- secret=encrypted_secret,
- events=events,
- is_active=True,
- ))
- db.commit()
- finally:
- db.close()
-
- return {"id": webhook_id, "name": name}
-
- @router.post("/webhooks/{webhook_id}/test")
- async def test_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
- if not wh:
- raise HTTPException(404, "Webhook not found")
- url, secret = wh.url, wh.secret
- finally:
- db.close()
-
- await webhook_manager.deliver_test(webhook_id, url, secret)
- return {"status": "sent"}
-
- @router.patch("/webhooks/{webhook_id}")
- def toggle_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
- if not wh:
- raise HTTPException(404, "Webhook not found")
- wh.is_active = not wh.is_active
- db.commit()
- return {"id": webhook_id, "is_active": wh.is_active}
- finally:
- db.close()
-
- @router.delete("/webhooks/{webhook_id}")
- def delete_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
- db.commit()
- if not deleted:
- raise HTTPException(404, "Webhook not found")
- finally:
- db.close()
- return {"status": "deleted"}
-
- # ================================================================
- # Sync Chat Endpoint (for n8n / Make / Activepieces)
- # ================================================================
-
- # Known provider base URLs — auto-resolved from api_key prefix or model name
- KNOWN_PROVIDERS = {
- "deepseek": "https://api.deepseek.com/v1",
- "openai": "https://api.openai.com/v1",
- "mistral": "https://api.mistral.ai/v1",
- "groq": "https://api.groq.com/openai/v1",
- "together": "https://api.together.xyz/v1",
- "openrouter": "https://openrouter.ai/api/v1",
- "ollama": "https://ollama.com/api",
- "opencode-zen": "https://opencode.ai/zen/v1",
- "opencode-go": "https://opencode.ai/zen/go/v1",
- "fireworks": "https://api.fireworks.ai/inference/v1",
- "venice": "https://api.venice.ai/api/v1",
- "kimi-code": "https://api.kimi.com/coding/v1",
- "kimicode": "https://api.kimi.com/coding/v1",
- }
-
- # Model prefix → provider mapping for auto-detection
- MODEL_PROVIDER_MAP = {
- "deepseek": "deepseek",
- "gpt-": "openai",
- "o1": "openai",
- "o3": "openai",
- "o4": "openai",
- "mistral": "mistral",
- "llama": "groq",
- "mixtral": "groq",
- "kimi-for-coding": "kimi-code",
- "kimi": "kimi-code",
- }
-
- def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
- """Try to auto-resolve a base URL from provider name or model prefix."""
- if provider and provider.lower() in KNOWN_PROVIDERS:
- return KNOWN_PROVIDERS[provider.lower()]
- if model:
- model_lower = model.lower()
- for prefix, prov in MODEL_PROVIDER_MAP.items():
- if model_lower.startswith(prefix):
- return KNOWN_PROVIDERS[prov]
- return None
-
- class SyncChatRequest(BaseModel):
- message: str = Field(..., max_length=MAX_MESSAGE_LEN)
- model: Optional[str] = Field(None, max_length=200)
- session: Optional[str] = Field(None, max_length=100)
- api_key: Optional[str] = Field(None, max_length=256)
- base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
- provider: Optional[str] = Field(None, max_length=50)
-
- @router.post("/v1/chat")
- async def sync_chat(request: Request, body: SyncChatRequest):
- if not getattr(request.state, "api_token", False):
- raise HTTPException(403, "This endpoint requires an API token")
- scopes = set(getattr(request.state, "api_token_scopes", []) or [])
- if "chat" not in scopes:
- raise HTTPException(403, "API token is not scoped for chat")
- token_owner = getattr(request.state, "api_token_owner", None)
-
- from core.models import ChatMessage
- from src.llm_core import llm_call_async
- from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
-
- message = body.message.strip()
- if not message:
- raise HTTPException(400, "Message is required")
-
- session_id = body.session
- sess = None
-
- # --- Case 1: Resume an existing session ---
- if session_id and session_manager:
- try:
- sess = session_manager.get_session(session_id)
- except (KeyError, Exception):
- raise HTTPException(404, "Session not found")
- # SECURITY: verify the API-token's user owns this session — without
- # this any token holder could resume any user's chat by passing its
- # ID. The token's user is on request.state.user (set by API-token
- # middleware); fall back to require_user if not present.
- try:
- from src.auth_helpers import get_current_user as _gcu
- _tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
- except Exception:
- _tok_user = None
- # Strict ownership (see _caller_owns_session): fail closed so a
- # null-owner / cross-owner session can't be resumed by an arbitrary
- # chat-scoped token.
- _sess_owner = getattr(sess, "owner", None)
- if not _caller_owns_session(_sess_owner, _tok_user):
- raise HTTPException(404, "Session not found")
-
- # --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
- if not sess and body.api_key:
- api_key = body.api_key.strip()
- model = body.model or "deepseek-chat"
-
- # Validate only token-supplied direct base_url; auto-resolved known-provider
- # URLs are not subject to extra local/LAN blocking beyond existing provider logic.
- direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
- if direct_base_url:
- try:
- base_url = validate_public_http_url(direct_base_url)
- except ValueError as e:
- detail = str(e).replace("URL", "base_url", 1)
- raise HTTPException(400, detail)
- else:
- base_url = _resolve_base_url(model, body.provider)
- if not base_url:
- raise HTTPException(400,
- "Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
- "or provider ('deepseek', 'openai', 'groq', etc.)")
- base_url = normalize_base(base_url)
- endpoint_url = build_chat_url(base_url)
-
- if not session_manager:
- raise HTTPException(500, "Session manager not available")
-
- sid = str(uuid.uuid4())
- sess = session_manager.create_session(
- session_id=sid, name="API Chat", endpoint_url=endpoint_url,
- model=model, owner=token_owner,
- )
- sess.headers = build_headers(api_key, base_url)
- session_manager.save_sessions()
- session_id = sid
-
- # --- Case 3: Fall back to first configured ModelEndpoint ---
- if not sess:
- db = SessionLocal()
- try:
- ep = _select_api_chat_fallback_endpoint(db, token_owner)
- finally:
- db.close()
-
- if not ep:
- raise HTTPException(400,
- "No session, api_key, or configured endpoints. "
- "Pass api_key + model, or configure an endpoint in Admin.")
-
- base_url = normalize_base(ep.base_url)
- endpoint_url = build_chat_url(base_url)
- model = body.model or "auto"
- api_key = ep.api_key
- if getattr(ep, "provider_auth_id", None):
- try:
- from src.endpoint_resolver import resolve_endpoint_runtime
- base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
- endpoint_url = build_chat_url(base_url)
- except Exception:
- raise HTTPException(500, "Could not resolve endpoint credentials")
-
- if model == "auto":
- try:
- async with httpx.AsyncClient(timeout=5) as client:
- models_url = build_models_url(base_url)
- hdrs = build_headers(api_key, base_url)
- if models_url:
- resp = await client.get(models_url, headers=hdrs)
- resp.raise_for_status()
- data = resp.json()
- items = data if isinstance(data, list) else (data.get("data") or [])
- ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
- if not ids and isinstance(data, dict):
- ids = [
- m.get("name") or m.get("model")
- for m in (data.get("models") or [])
- if m.get("name") or m.get("model")
- ]
- else:
- import json as _json
- ids = _json.loads(ep.cached_models or "[]")
- model = ids[0] if ids else "auto"
- except Exception:
- raise HTTPException(500, "Could not discover models from endpoint")
-
- if not session_manager:
- raise HTTPException(500, "Session manager not available")
-
- sid = str(uuid.uuid4())
- sess = session_manager.create_session(
- session_id=sid, name="API Chat", endpoint_url=endpoint_url,
- model=model, owner=token_owner,
- )
- if api_key:
- sess.headers = build_headers(api_key, base_url)
- session_manager.save_sessions()
- session_id = sid
-
- # --- Send message and get response ---
- sess.add_message(ChatMessage("user", message))
-
- messages = [{"role": m.role, "content": m.content} for m in sess.history]
-
- reply = await llm_call_async(
- sess.endpoint_url, sess.model, messages,
- headers=sess.headers, timeout=120,
- )
- sess.add_message(ChatMessage("assistant", reply))
- session_manager.save_sessions()
-
- webhook_manager.fire_and_forget("chat.completed", {
- "session_id": session_id, "model": sess.model,
- "user_message": message[:2000], "response": reply[:2000],
- })
-
- return {"response": reply, "session_id": session_id, "model": sess.model}
-
- return router
+_sys.modules[__name__] = _canonical
diff --git a/services/memory/__init__.py b/services/memory/__init__.py
index 53fc80bd8..31fa1d5fa 100644
--- a/services/memory/__init__.py
+++ b/services/memory/__init__.py
@@ -2,7 +2,7 @@
"""Memory service — persistent memory storage and retrieval."""
from .service import MemoryService, Memory, MemorySearchResult
-from .memory import MemoryManager
+from .memory import MemoryManager, MemoryStoreUnreadable
from .memory_vector import MemoryVectorStore
__all__ = [
@@ -10,5 +10,6 @@ __all__ = [
"Memory",
"MemorySearchResult",
"MemoryManager",
+ "MemoryStoreUnreadable",
"MemoryVectorStore",
]
diff --git a/services/memory/memory.py b/services/memory/memory.py
index 031c13ac4..b9aaaa2a8 100644
--- a/services/memory/memory.py
+++ b/services/memory/memory.py
@@ -5,6 +5,16 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
parallel implementation here risks silent drift between import paths.
"""
-from src.memory import MemoryManager, get_text_similarity, tokenize
+from src.memory import (
+ MemoryManager,
+ MemoryStoreUnreadable,
+ get_text_similarity,
+ tokenize,
+)
-__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
+__all__ = [
+ "MemoryManager",
+ "MemoryStoreUnreadable",
+ "get_text_similarity",
+ "tokenize",
+]
diff --git a/services/memory/memory_extractor.py b/services/memory/memory_extractor.py
index e5f609250..11539263b 100644
--- a/services/memory/memory_extractor.py
+++ b/services/memory/memory_extractor.py
@@ -17,6 +17,8 @@ import os
import re
from typing import Optional
+from src.memory import MemoryStoreUnreadable
+
logger = logging.getLogger(__name__)
@@ -387,7 +389,13 @@ async def extract_and_store(
# Get owner from session
_owner = getattr(session, 'owner', None)
- existing = memory_manager.load_all()
+ # Strict load: this is a read-modify-write. Degrading to [] here would
+ # save only the newly extracted facts and drop the entire store.
+ try:
+ existing = memory_manager.load_all_for_update()
+ except MemoryStoreUnreadable as e:
+ logger.error("Skipping auto memory extraction, store unreadable: %s", e)
+ return
added = 0
for fact in facts:
@@ -626,7 +634,18 @@ async def audit_memories(
# Merge audited entries back with other users' entries
if owner:
- all_entries = memory_manager.load_all()
+ # Strict load: the merge below reconstructs the whole file. If this
+ # degraded to [] we would save only this owner's audited slice and
+ # destroy every other tenant's memories.
+ try:
+ all_entries = memory_manager.load_all_for_update()
+ except MemoryStoreUnreadable as e:
+ logger.error("Aborting memory audit save, store unreadable: %s", e)
+ return {
+ "before": before_count,
+ "after": before_count,
+ "error": "store_unreadable",
+ }
audited_ids = {e["id"] for e in final_entries}
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
# Also keep legacy entries that weren't part of this audit
diff --git a/services/memory/skill_format.py b/services/memory/skill_format.py
index 2b2dfb1b3..628474b04 100644
--- a/services/memory/skill_format.py
+++ b/services/memory/skill_format.py
@@ -50,7 +50,7 @@ import json
import logging
import re
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -441,4 +441,4 @@ class Skill:
def _now_iso() -> str:
- return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py
index 2120d7720..dd37865a7 100644
--- a/services/tts/tts_service.py
+++ b/services/tts/tts_service.py
@@ -2,6 +2,7 @@
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
import io
+import os
import wave
import logging
import hashlib
@@ -41,6 +42,11 @@ class TTSService:
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._kokoro = None # lazy-init
+
+ try:
+ self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
+ except ValueError:
+ self.max_cache_bytes = 500 * 1024 * 1024
# ── Settings ──
@@ -89,6 +95,53 @@ class TTSService:
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
(self.cache_dir / f"{key}{ext}").write_bytes(data)
+ self._enforce_cache_limit()
+
+ def _enforce_cache_limit(self):
+ """Evicts oldest files if the cache exceeds the configured byte limit."""
+ if self.max_cache_bytes <= 0:
+ return
+
+ try:
+ files = []
+ total_size = 0
+
+ # Safely scan files and sum sizes, ignoring files deleted mid-scan
+ for f in self.cache_dir.iterdir():
+ try:
+ if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
+ files.append(f)
+ total_size += f.stat().st_size
+ except OSError:
+ continue
+
+ if total_size > self.max_cache_bytes:
+ logger.info(
+ f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
+ )
+
+ # Sort files by modification time (oldest first)
+ try:
+ files.sort(key=lambda f: f.stat().st_mtime)
+ except OSError as e:
+ logger.warning(f"Failed to sort cache files by mtime: {e}")
+
+ # Trim down to 80% of max capacity
+ target_size = self.max_cache_bytes * 0.8
+
+ while files and total_size > target_size:
+ f = files.pop(0)
+ try:
+ size = f.stat().st_size
+ f.unlink()
+ total_size -= size
+ except OSError as e:
+ logger.warning(f"Failed to evict cache file {f}: {e}")
+ continue
+
+ except Exception as e:
+ logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
+
def clear_cache(self):
count = 0
for f in self.cache_dir.glob("*.*"):
diff --git a/src/agent_loop.py b/src/agent_loop.py
index 592ebaec1..cca93fe56 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -12,7 +12,7 @@ import json
import re
import time
import logging
-from typing import AsyncGenerator, List, Dict, Optional, Set
+from typing import Any, AsyncGenerator, List, Dict, Optional, Set
from urllib.parse import urlparse
from src.llm_core import (
diff --git a/src/ai_interaction.py b/src/ai_interaction.py
index 9ee97368f..e777ca32a 100644
--- a/src/ai_interaction.py
+++ b/src/ai_interaction.py
@@ -22,6 +22,7 @@ import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
+from src.memory import MemoryStoreUnreadable
logger = logging.getLogger(__name__)
@@ -384,7 +385,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Memory text cannot be empty"}
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
- memories = _memory_manager.load_all()
+ # Strict load: this is a read-modify-write, and it is the path an
+ # ordinary "remember that I prefer X" takes. Degrading to [] here would
+ # save just this one entry over a store we only failed to read,
+ # atomically destroying every memory in it (issue #5673).
+ try:
+ memories = _memory_manager.load_all_for_update()
+ except MemoryStoreUnreadable as e:
+ logger.error("Refusing to add memory, store unreadable: %s", e)
+ return {"error": "Memory store is temporarily unreadable — nothing was saved."}
memories.append(entry)
_memory_manager.save(memories)
diff --git a/src/integrations.py b/src/integrations.py
index aa6c4982e..52dd4b2d1 100644
--- a/src/integrations.py
+++ b/src/integrations.py
@@ -1,11 +1,14 @@
+import ipaddress
import json
import os
+import time
import uuid
import logging
import re
from typing import Dict, List, Optional, Any
from urllib.parse import urljoin, urlparse, urlunparse
+import httpcore
import httpx
from fastapi import HTTPException
@@ -354,6 +357,152 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
return None
+# httpcore raises its own exception hierarchy; map the ones a simple request can
+# surface back to their httpx equivalents so the caller's `except httpx.*` blocks
+# below behave exactly as they did with the default transport.
+_HTTPCORE_TO_HTTPX_EXC = {
+ httpcore.ConnectError: httpx.ConnectError,
+ httpcore.ConnectTimeout: httpx.ConnectTimeout,
+ httpcore.NetworkError: httpx.NetworkError,
+ httpcore.PoolTimeout: httpx.PoolTimeout,
+ httpcore.ProtocolError: httpx.ProtocolError,
+ httpcore.ReadError: httpx.ReadError,
+ httpcore.ReadTimeout: httpx.ReadTimeout,
+ httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
+ httpcore.TimeoutException: httpx.TimeoutException,
+ httpcore.WriteError: httpx.WriteError,
+ httpcore.WriteTimeout: httpx.WriteTimeout,
+}
+
+
+class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
+ """Network backend that connects only to the pre-validated IPs, in order.
+
+ Every address here came out of the single SSRF resolution, so moving to the
+ next one after a connect failure is not re-resolution — it's ordinary
+ multi-address fallback restricted to the set the guard already approved.
+ httpcore takes TLS SNI and the ``Host`` header from the request URL rather
+ than the connect host, so pinning the socket destination leaves certificate
+ validation and vhost routing pointed at the original hostname.
+ """
+
+ def __init__(self, ips: List[ipaddress._BaseAddress]):
+ self._ips = [str(ip) for ip in ips]
+ self._real = httpcore.AnyIOBackend()
+
+ async def connect_tcp(self, host, port, timeout=None, local_address=None,
+ socket_options=None):
+ # One shared connect budget: each attempt gets the time left until the
+ # original deadline, so N dead addresses can't stretch the connect
+ # phase to N * timeout.
+ deadline = None if timeout is None else time.monotonic() + timeout
+ last_exc: Optional[Exception] = None
+ for ip in self._ips:
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
+ try:
+ return await self._real.connect_tcp(
+ ip, port, remaining, local_address, socket_options
+ )
+ except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
+ last_exc = exc
+ if deadline is not None and time.monotonic() >= deadline:
+ break
+ raise last_exc
+
+ async def connect_unix_socket(self, path, timeout=None, socket_options=None):
+ return await self._real.connect_unix_socket(path, timeout, socket_options)
+
+ async def sleep(self, seconds: float) -> None:
+ return await self._real.sleep(seconds)
+
+
+class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
+ """httpx transport that pins the TCP connect to the pre-resolved IP(s).
+
+ Kept local, mirroring the per-module pinned transports web fetch and
+ webhook delivery already carry, rather than coupling api_call to the
+ webhook subsystem. The request URL passes through unchanged, so SNI and the
+ ``Host`` header stay the original hostname; only the socket destination is
+ pinned, which is what closes the rebinding window.
+ """
+
+ def __init__(self, ips: List[ipaddress._BaseAddress]):
+ self._pinned_ips = list(ips)
+ self._pool = httpcore.AsyncConnectionPool(
+ # Reuse the CA trust the default httpx client would build (certifi
+ # plus SSL_CERT_FILE / SSL_CERT_DIR when trust_env is set) so
+ # swapping in this transport doesn't quietly change which chains
+ # verify. ssl.create_default_context() would use system roots.
+ ssl_context=httpx.create_ssl_context(),
+ http1=True,
+ http2=False,
+ network_backend=_PinnedAsyncBackend(ips),
+ )
+
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ core_req = httpcore.Request(
+ method=request.method,
+ url=httpcore.URL(
+ scheme=request.url.raw_scheme,
+ host=request.url.raw_host,
+ port=request.url.port,
+ target=request.url.raw_path,
+ ),
+ headers=request.headers.raw,
+ content=request.stream,
+ extensions=request.extensions,
+ )
+ try:
+ core_resp = await self._pool.handle_async_request(core_req)
+ content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
+ await core_resp.aclose()
+ except Exception as exc:
+ mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
+ if mapped is not None:
+ raise mapped(str(exc)) from exc
+ raise
+ return httpx.Response(
+ status_code=core_resp.status,
+ headers=core_resp.headers,
+ content=content,
+ extensions=core_resp.extensions,
+ )
+
+ async def aclose(self) -> None:
+ await self._pool.aclose()
+
+
+def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
+ """Return every entry that parses as an IP address, de-duplicated, order
+ preserved.
+
+ check_outbound_url only reports ok when *all* of these classify as safe, so
+ the whole list is guard-approved and any of them is a legitimate connect
+ target. Skipping unparseable entries mirrors how the guard walks the same
+ resolver output.
+
+ De-duplication matters because the resolver is getaddrinfo(host, None) with
+ no socktype filter, so glibc reports the same address once per socktype
+ (SOCK_STREAM/SOCK_DGRAM/SOCK_RAW) — a single-homed host comes back three
+ times. Without this, the connect fallback would spend the shared deadline
+ retrying one dead address instead of moving on to a genuinely different one.
+ """
+ ips: List[ipaddress._BaseAddress] = []
+ seen = set()
+ for raw in raw_ips:
+ if not isinstance(raw, str):
+ continue
+ try:
+ ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
+ except ValueError:
+ continue
+ if ip in seen:
+ continue
+ seen.add(ip)
+ ips.append(ip)
+ return ips
+
+
async def execute_api_call(
integration_id: str,
method: str,
@@ -409,13 +558,31 @@ async def execute_api_call(
# loopback for locked-down deployments. Private stays allowed by default
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
# primary use case.
- from src.url_safety import check_outbound_url
+ from src.url_safety import check_outbound_url, _default_resolver
block_private = os.getenv(
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
).lower() == "true"
- ok, reason = check_outbound_url(url, block_private=block_private)
+ # Resolve the host exactly once and remember the IPs the guard validated so
+ # the request below can be pinned to them. check_outbound_url only reports
+ # (ok, reason); a plain httpx client re-resolves the host at connect time,
+ # which reopens a DNS-rebinding TOCTOU — a base_url host that answers with a
+ # public IP for the guard and then flips to 169.254.169.254 for the connect
+ # would reach cloud metadata with the integration's auth headers attached.
+ resolved_ips: List[str] = []
+
+ def _recording_resolver(host: str) -> List[str]:
+ ips = _default_resolver(host)
+ resolved_ips[:] = ips
+ return ips
+
+ ok, reason = check_outbound_url(
+ url, block_private=block_private, resolver=_recording_resolver
+ )
if not ok:
return {"error": f"URL rejected: {reason}", "exit_code": 1}
+ pinned_ips = _validated_ips(resolved_ips)
+ if not pinned_ips:
+ return {"error": "URL rejected: host did not resolve to a usable address", "exit_code": 1}
method = method.upper()
@@ -455,7 +622,9 @@ async def execute_api_call(
auth = httpx.BasicAuth(parts[0], parts[1])
try:
- async with httpx.AsyncClient(timeout=30.0) as client:
+ async with httpx.AsyncClient(
+ timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
+ ) as client:
response = await client.request(
method,
url,
diff --git a/src/llm_core.py b/src/llm_core.py
index 4dec32376..3e84c1060 100644
--- a/src/llm_core.py
+++ b/src/llm_core.py
@@ -1237,15 +1237,27 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False
# `(?= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
- # 20260201`) keep their explicit minor and are still matched.
- match = re.search(r"(?= 4.7 (issue #5753). Without
+ # this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
+ # only on paths that pass a temperature, e.g. scheduled tasks inheriting
+ # `stream_agent_loop`'s 0.3 default, which returned empty responses.
+ match = re.search(
+ r"(?= (4, 7)
+ major = int(match.group(1))
+ minor = int(match.group(2)) if match.group(2) else 0
+ return (major, minor) >= (4, 7)
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
diff --git a/src/memory.py b/src/memory.py
index 1d8cdbc1e..92efbf5b2 100644
--- a/src/memory.py
+++ b/src/memory.py
@@ -10,6 +10,18 @@ from datetime import datetime
logger = logging.getLogger(__name__)
+
+class MemoryStoreUnreadable(RuntimeError):
+ """memory.json exists on disk but could not be read or parsed.
+
+ "The contents are unknown" is categorically different from "there are no
+ memories". A read-modify-write caller that conflates the two appends to an
+ empty view and then persists it, destroying the whole store — the writes
+ are atomic, so the loss is durable. Raised by
+ :meth:`MemoryManager.load_all_for_update` so those callers fail closed.
+ """
+
+
def tokenize(text: str) -> List[str]:
"""Simple tokenizer that splits on whitespace and removes punctuation."""
return [word.strip('.,!?";') for word in text.split()]
@@ -110,21 +122,69 @@ class MemoryManager:
with open(self.memory_file, 'w', encoding='utf-8') as f:
json.dump([], f, ensure_ascii=False, indent=2)
- def load_all(self) -> List[Dict]:
- """Load all memory entries from JSON file (unfiltered)."""
+ def _read_entries(self) -> List[Dict]:
+ """Parse the store, or raise :class:`MemoryStoreUnreadable`.
+
+ Returns ``[]`` only when the file genuinely does not exist. Every other
+ failure mode raises, so callers can tell "no memories" apart from
+ "couldn't read the memories".
+ """
if not os.path.exists(self.memory_file):
return []
try:
with open(self.memory_file, "r", encoding="utf-8") as f:
data = json.load(f)
- if isinstance(data, list):
- return self._validate_entries(data)
- except (json.JSONDecodeError, PermissionError) as e:
- logger.error("Error loading memory.json: %s", e)
- return self._migrate_from_legacy()
+ except OSError as e:
+ # PermissionError is an OSError (a scanner holding the file, a
+ # permissions problem, bad media).
+ raise MemoryStoreUnreadable(
+ f"cannot read {self.memory_file}: {e}"
+ ) from e
+ except json.JSONDecodeError as e:
+ # This is the branch that actually destroyed stores: the file reads
+ # back fine, so nothing stops the save that follows. A truncated
+ # memory.json is reachable because core/database.py rewrites it with
+ # a plain open(..,"w") + json.dump during migration.
+ #
+ # Preserved behaviour: a corrupt store still gets one shot at the
+ # pre-JSON memory.txt migration. Only raise when that finds nothing,
+ # so we never report "empty" for a store we simply failed to parse.
+ legacy = self._migrate_from_legacy()
+ if legacy:
+ return legacy
+ raise MemoryStoreUnreadable(
+ f"{self.memory_file} is not valid JSON: {e}"
+ ) from e
- return []
+ if not isinstance(data, list):
+ raise MemoryStoreUnreadable(
+ f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
+ )
+ return self._validate_entries(data)
+
+ def load_all(self) -> List[Dict]:
+ """Load all memory entries from JSON file (unfiltered).
+
+ Lenient by design: this feeds display, search, and context-injection
+ paths, so an unreadable store degrades to an empty list rather than
+ breaking chat. Never build a value from this that you intend to save
+ back — use :meth:`load_all_for_update` for that.
+ """
+ try:
+ return self._read_entries()
+ except MemoryStoreUnreadable as e:
+ logger.error("Error loading memory.json: %s", e)
+ return []
+
+ def load_all_for_update(self) -> List[Dict]:
+ """Load for a read-modify-write cycle.
+
+ Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
+ so a caller can never append to an empty view and persist it over a
+ store that was only temporarily unreadable (issue #5673).
+ """
+ return self._read_entries()
def load(self, owner: str = None) -> List[Dict]:
"""Load memory entries, optionally filtered by owner."""
@@ -135,7 +195,12 @@ class MemoryManager:
def claim_ownerless(self, owner: str):
"""Assign all ownerless memory entries to the given owner."""
- entries = self.load_all()
+ try:
+ entries = self.load_all_for_update()
+ except MemoryStoreUnreadable as e:
+ # Skip the sweep rather than rewrite the store from an unknown view.
+ logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
+ return
changed = False
claimed = 0
for entry in entries:
@@ -235,7 +300,12 @@ class MemoryManager:
if not ids:
return
id_set = set(ids)
- entries = self.load_all()
+ try:
+ entries = self.load_all_for_update()
+ except MemoryStoreUnreadable as e:
+ # Best-effort counter; never worth rewriting the store blind.
+ logger.error("Skipping uses bump, memory store unreadable: %s", e)
+ return
changed = False
for e in entries:
if e.get("id") in id_set:
diff --git a/src/memory_provider.py b/src/memory_provider.py
index 925c59192..8974a6e84 100644
--- a/src/memory_provider.py
+++ b/src/memory_provider.py
@@ -157,7 +157,11 @@ class NativeMemoryProvider(MemoryProvider):
if metadata:
entry["metadata"] = dict(metadata)
- memories = self.memory_manager.load_all()
+ # Strict load: read-modify-write. `load_all` degrades an unreadable
+ # store to [], which would save this single entry over everything
+ # already stored (issue #5673). The provider API has no error channel,
+ # so MemoryStoreUnreadable propagates to the caller.
+ memories = self.memory_manager.load_all_for_update()
memories.append(entry)
self.memory_manager.save(memories)
@@ -223,7 +227,10 @@ class NativeMemoryProvider(MemoryProvider):
]
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
- memories = self.memory_manager.load_all()
+ # Strict load for the same reason: `remaining` is derived from this
+ # list and saved back, so it must never be built from a store we
+ # failed to read.
+ memories = self.memory_manager.load_all_for_update()
remaining = []
deleted_id = None
diff --git a/src/tool_parsing.py b/src/tool_parsing.py
index 2885cc00f..98dc1b5f6 100644
--- a/src/tool_parsing.py
+++ b/src/tool_parsing.py
@@ -187,8 +187,12 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"\s*", re.IGNORECASE)
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r" ", re.IGNORECASE)
_QWEN_ROLE_MARKER_RE = re.compile(r"?\|(?:assistant|assistan|user|system|tool)\|>?|\|end\|>?", re.IGNORECASE)
+# At least one pipe is required around `end`. Both pipes used to be optional
+# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
+# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
+# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
_QWEN_BARE_MARKER_RE = re.compile(
- r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
+ r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
re.IGNORECASE,
)
diff --git a/src/tools/system.py b/src/tools/system.py
index 813d57df2..c2eb9ceab 100644
--- a/src/tools/system.py
+++ b/src/tools/system.py
@@ -46,7 +46,9 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
- action = (args.get("action") or "").lower()
+ action = (args.get("action") or "").strip().lower()
+ if not action:
+ return {"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)", "exit_code": 1}
from services.memory.skills import SkillsManager
from services.memory.skill_format import Skill, slugify
from src.constants import DATA_DIR
@@ -55,7 +57,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
# Accept legacy `skill_id` as an alias for `name`.
name = (args.get("name") or args.get("skill_id") or "").strip()
- if action in ("list", "index", ""):
+ if action in ("list", "index"):
all_skills = sm.load(owner=owner)
if not all_skills:
return {"results": "No skills yet. Create one with action='add'."}
diff --git a/static/app.js b/static/app.js
index 97f0ae77e..2f1e8d4bf 100644
--- a/static/app.js
+++ b/static/app.js
@@ -10,14 +10,14 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
-import chatModule from './js/chat.js?v=20260722ctxheader4';
+import chatModule from './js/chat.js?v=20260801fix1';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
-import sessionModule from './js/sessions.js?v=20260722ctxheader4';
+import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
@@ -1689,12 +1689,20 @@ function initializeEventListeners() {
const newMemoryInput = el('new-memory-input');
if (newMemoryInput) {
- newMemoryInput.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
+ // keydown, not the deprecated keypress: keypress is not guaranteed to
+ // fire for Enter everywhere, which left the Add Memory form with no
+ // working submit path (#5828).
+ newMemoryInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.isComposing) {
+ e.preventDefault();
memoryModule.addNewMemory();
}
});
}
+ const newMemoryAddBtn = el('new-memory-add-btn');
+ if (newMemoryAddBtn) {
+ newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
+ }
// Voice recording is handled by the dual-purpose send/mic button (see below)
@@ -3908,85 +3916,10 @@ function startOdysseusApp() {
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
- function _readComposerPromptHistory() {
- const chatBox = document.getElementById('chat-history');
- if (!chatBox) return [];
- return Array.from(chatBox.querySelectorAll('.msg-user'))
- .reverse()
- .map(msg => {
- const body = msg.querySelector('.body');
- return msg.dataset?.raw || (body ? body.textContent : '') || '';
- })
- .filter(Boolean);
- }
-
- if (messageInput && !messageInput._odysseusPromptRecallCapture) {
- messageInput._odysseusPromptRecallCapture = true;
- let recallHistory = [];
- let recallIndex = -1;
- let lastRecalled = '';
- const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
- messageInput.addEventListener('input', () => {
- if (norm(messageInput.value) === norm(lastRecalled)) return;
- recallHistory = [];
- recallIndex = -1;
- lastRecalled = '';
- try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
- }, true);
- messageInput.addEventListener('keydown', (e) => {
- if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
- if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
- if (window._ghostAutocomplete?.isActive?.()) return;
- const fresh = _readComposerPromptHistory();
- const history = fresh.length ? fresh : recallHistory;
- if (!history.length) return;
- const current = norm(messageInput.value);
- let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
- if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
- if (current && currentIndex < 0) {
- const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
- if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
- currentIndex = markedIndex;
- }
- }
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
- if (e.key === 'ArrowDown') {
- if (currentIndex < 0) return;
- const nextIndex = currentIndex - 1;
- if (nextIndex < 0) {
- recallHistory = history;
- recallIndex = -1;
- lastRecalled = '';
- try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
- messageInput.value = '';
- try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
- try { uiModule.autoResize(messageInput); } catch {}
- return;
- }
- const recalled = history[nextIndex];
- recallHistory = history;
- recallIndex = nextIndex;
- lastRecalled = recalled;
- try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
- messageInput.value = recalled;
- try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
- try { uiModule.autoResize(messageInput); } catch {}
- return;
- }
- const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
- const recalled = history[nextIndex];
- if (!recalled) return;
- recallHistory = history;
- recallIndex = nextIndex;
- lastRecalled = recalled;
- try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
- messageInput.value = recalled;
- try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
- try { uiModule.autoResize(messageInput); } catch {}
- }, true);
- }
+ // ArrowUp/ArrowDown prompt recall on #message lives in
+ // static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
+ // copy here: two capture-phase listeners on the same textarea meant the one
+ // without the draft guard won and ate unsent multi-line prompts (#5862).
const _sendIcon = ' ';
const _micIcon = ' ';
diff --git a/static/index.html b/static/index.html
index 8257660fe..fea4e20ac 100644
--- a/static/index.html
+++ b/static/index.html
@@ -250,9 +250,9 @@
-
+
-
+
@@ -365,6 +365,7 @@
Add a memory — e.g. 'I prefer concise replies'
+ Add
@@ -1005,7 +1006,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
- el.textContent = 'Pick a model if you want, or just type.';
+ el.textContent = tips[Math.floor(Math.random() * tips.length)];
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -2504,7 +2505,7 @@
-
+
@@ -2522,7 +2523,7 @@
-
+
diff --git a/static/js/chat.js b/static/js/chat.js
index ea2d8c1bb..3c8bbe850 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -349,6 +349,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
async function _adoptOpenedSessionBeforeAutoCreate() {
if (!sessionModule || !sessionModule.getCurrentSessionId || sessionModule.getCurrentSessionId()) return true;
+ // Don't adopt a stale session when the user explicitly started a New Chat
+ // (pending state set) — the send path must materialize the pending session.
+ if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) return false;
const activeRowId = document.querySelector('.list-item.active-session[data-session-id], .session-item.active[data-session-id]')?.dataset?.sessionId || '';
const hashId = _hashSessionCandidate();
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
@@ -1403,6 +1406,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
currentAccumulated = '';
currentHolder = null;
+ let abortCtrl = null;
+ let streamingTTS = false;
try {
// Re-enable auto-scroll when user sends a message
uiModule.setAutoScroll(true);
@@ -1716,7 +1721,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
- const abortCtrl = new AbortController();
+ abortCtrl = new AbortController();
abortCtrl._reason = '';
currentAbort = abortCtrl;
@@ -1897,7 +1902,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let isThinking = false;
let thinkingStartTime = null;
// Streaming TTS: synthesize sentence-by-sentence during streaming
- const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
+ streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
if (streamingTTS) window.aiTTSManager.streamingStart();
// Multi-bubble agent tracking
let roundHolder = holder; // Current AI text bubble (changes per round)
@@ -4787,7 +4792,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (msgIndex < 0) return;
const bodyEl = userMsgElement.querySelector('.body');
- const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
+ let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
+ currentText = currentText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Replace body with an editable textarea
const editor = document.createElement('textarea');
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index 10709679d..1d6e2e4a9 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -478,7 +478,10 @@ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
-const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
+// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
+// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
+// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
+const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
diff --git a/static/js/composerArrowUpRecall.js b/static/js/composerArrowUpRecall.js
index e0b20d6b4..83141bfe9 100644
--- a/static/js/composerArrowUpRecall.js
+++ b/static/js/composerArrowUpRecall.js
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
return;
}
- // ArrowUp owns prompt history in the chat composer. If the current text
- // is not already a recalled prompt, start from newest instead of letting
- // the browser move the caret inside the textarea.
+ // ArrowUp walks older prompts. An unmatched draft already returned above,
+ // so reaching here means the composer is empty or holds a recalled prompt
+ // — the caret-navigation case is never hijacked.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index 6a0d3e294..32b906ddc 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import {
_esc, _escLinkify, _extractName, _parseTurnMeta,
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
- _sanitizeHtml,
+ _sanitizeHtml, _renderEmailSummaryError,
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
} from './emailLibrary/utils.js';
@@ -7259,12 +7259,11 @@ async function _generateSummary(reader, data, btn) {
if (label) label.textContent = 'Summary';
}
} else {
- content.innerHTML = `
${_esc(result.error || 'Failed to summarize')} `;
- panel.remove();
+ _renderEmailSummaryError(content, result);
}
} catch (e) {
sp.destroy();
- panel.remove();
+ _renderEmailSummaryError(content, null);
if (uiModule) uiModule.showError?.('Failed to summarize');
} finally {
if (btn) btn.disabled = false;
diff --git a/static/js/emailLibrary/utils.js b/static/js/emailLibrary/utils.js
index 82a5c86ec..f634c9949 100644
--- a/static/js/emailLibrary/utils.js
+++ b/static/js/emailLibrary/utils.js
@@ -30,6 +30,25 @@ export function _esc(text) {
return div.innerHTML;
}
+const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
+ email_summary_missing_body: 'No email body to summarize',
+ email_summary_not_configured: 'No model configured for email summaries',
+ email_summary_empty: 'The model returned an empty summary',
+ email_summary_unavailable: 'Failed to summarize',
+});
+
+export function _emailSummaryErrorMessage(result) {
+ const code = String(result?.error_code || '');
+ return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
+}
+
+export function _renderEmailSummaryError(container, result) {
+ const message = container.ownerDocument.createElement('span');
+ message.style.color = 'var(--red)';
+ message.textContent = _emailSummaryErrorMessage(result);
+ container.replaceChildren(message);
+}
+
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '"')
diff --git a/static/js/markdown.js b/static/js/markdown.js
index 8735b83e7..f249facc9 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -758,30 +758,36 @@ export function mdToHtml(src, opts) {
// Remove empty paragraphs
s = s.replace(/
<\/p>/g, '');
+ // Every restore below passes a function replacer rather than the block string
+ // itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
+ // `$'` and `$$` in the *replacement* as substitution patterns, so a restored
+ // block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
+ // and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
+ // sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
+ // `echo "$$USD"`). A function replacer inserts its return value verbatim.
+
// CRITICAL: Restore allowed HTML blocks first
allowedHtmlBlocks.forEach((block, index) => {
- s = s.replace(`___ALLOWED_HTML_${index}___`, block);
+ s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
});
// Restore math blocks
mathBlocks.forEach((block, index) => {
- s = s.replace(`___MATH_BLOCK_${index}___`, block);
+ s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
});
// Restore mermaid diagram blocks
mermaidBlocks.forEach((block, index) => {
- s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
+ s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
});
// CRITICAL: Restore code blocks at the end
codeBlocks.forEach((block, index) => {
- s = s.replace(`___CODE_BLOCK_${index}___`, block);
+ s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
});
// Restore inline code spans last, so placeholders carried inside restored
- // /allowed-HTML blocks are resolved too. The function replacer keeps the
- // escaped code literal — e.g. a shell snippet like `echo $1` is not treated
- // as a regex back-reference.
+ // /allowed-HTML blocks are resolved too.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
diff --git a/static/js/sessions.js b/static/js/sessions.js
index cf59d478c..edf83c8a4 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -1847,6 +1847,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
if (!_isTransientChat) {
Storage.set('lastSessionId', id);
+ // Update URL hash without triggering hashchange handler
+ if (window.location.hash !== '#' + id) {
+ history.replaceState(null, '', '#' + id);
+ }
}
// Restore character preset for persistent chats
try {
@@ -2313,6 +2317,7 @@ export async function materializePendingSession() {
currentSessionId = payload.id;
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
+ history.replaceState(null, '', '#' + payload.id);
}
// Reload the sidebar in the background. Awaiting this used to block the first
diff --git a/static/js/settings.js b/static/js/settings.js
index 72936adee..540acff00 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -3031,12 +3031,14 @@ async function initEmailAccountsSettings() {
const body = {
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
from_address: el('eaf-from').value.trim(),
+ display_name: el('eaf-display-name').value.trim(),
imap_host: el('eaf-imap-host').value.trim(),
imap_port: parseInt(el('eaf-imap-port').value) || 993,
imap_user: el('eaf-imap-user').value.trim(),
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
+ smtp_security: el('eaf-smtp-security').value,
smtp_user: el('eaf-imap-user').value.trim(),
};
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
@@ -5788,29 +5790,30 @@ export function close() {
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
- // Open settings → integrations after the app has initialised.
- function _tryOpen() {
- if (window.settingsModule && typeof window.settingsModule.open === 'function') {
- window.settingsModule.open('integrations');
- // Brief toast-style banner.
- const banner = document.createElement('div');
- banner.textContent = success
- ? '✓ Google account connected — email is ready'
- : `Google OAuth failed: ${errMsg || 'unknown error'}`;
- Object.assign(banner.style, {
- position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
- background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
- color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
- fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
- boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
- });
- document.body.appendChild(banner);
- setTimeout(() => banner.remove(), 4000);
- } else {
- setTimeout(_tryOpen, 100);
- }
+ // Open settings → integrations once the document is ready. This module owns
+ // the open() API, so it does not need to wait for a window-level alias.
+ function _showResult() {
+ open('integrations');
+ // Brief toast-style banner.
+ const banner = document.createElement('div');
+ banner.textContent = success
+ ? 'Google account connected — email is ready'
+ : `Google OAuth failed: ${errMsg || 'unknown error'}`;
+ Object.assign(banner.style, {
+ position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
+ background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
+ color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
+ fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
+ boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
+ });
+ document.body.appendChild(banner);
+ setTimeout(() => banner.remove(), 4000);
+ }
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', _showResult, { once: true });
+ } else {
+ _showResult();
}
- _tryOpen();
})();
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
diff --git a/static/js/skills.js b/static/js/skills.js
index 84974d446..b45403570 100644
--- a/static/js/skills.js
+++ b/static/js/skills.js
@@ -83,11 +83,9 @@ export async function loadSkills(cascade = false) {
// Play the domino-in entrance on this load (set when the tab is opened,
// not for the silent re-loads after an edit/delete).
if (cascade) _cascadeNext = true;
- if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
- _cascadeNext = false;
- updateCount();
- return;
- }
+ // Always re-fetch when the tab is explicitly opened — the cascade
+ // animation is handled inside renderSkillsList() via _cascadeNext.
+ // Skipping the fetch here caused stale data on panel close/reopen (#5870).
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
try {
diff --git a/tests/test_api_chat_security.py b/tests/test_api_chat_security.py
index 7dcec324e..d92a31620 100644
--- a/tests/test_api_chat_security.py
+++ b/tests/test_api_chat_security.py
@@ -76,7 +76,7 @@ def _load_webhook_routes_for_test(monkeypatch):
module_name = "routes.webhook_routes_under_test"
spec = importlib.util.spec_from_file_location(
module_name,
- Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
+ Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/tests/test_backup_import_cross_user_dedup.py b/tests/test_backup_import_cross_user_dedup.py
index 2df5936ef..135be78ee 100644
--- a/tests/test_backup_import_cross_user_dedup.py
+++ b/tests/test_backup_import_cross_user_dedup.py
@@ -27,6 +27,9 @@ def _setup(monkeypatch, store, user="alice"):
mem = MagicMock()
mem.load_all.return_value = list(store)
+ # import_data reads through the strict loader so a store it cannot read is
+ # never overwritten (#5673); the double has to offer the same entry point.
+ mem.load_all_for_update.return_value = list(store)
saved = {}
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
diff --git a/tests/test_composer_arrow_up_recall_js.py b/tests/test_composer_arrow_up_recall_js.py
index eadc3bc94..022fcbc02 100644
--- a/tests/test_composer_arrow_up_recall_js.py
+++ b/tests/test_composer_arrow_up_recall_js.py
@@ -306,3 +306,24 @@ def test_integration_recalls_from_chat_history_dom():
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
+
+
+def test_prompt_recall_is_not_duplicated_in_app_js():
+ """Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
+
+ static/app.js once carried a near-verbatim copy of this recall logic, wired
+ as a second capture-phase listener on the same textarea. That copy lacked
+ the draft guard here, and because it called stopImmediatePropagation it won
+ regardless of registration order — so a typed multi-line prompt was replaced
+ by the last sent one instead of the caret moving up a line.
+ """
+ app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
+ for marker in (
+ "_odysseusPromptRecallCapture",
+ "_readComposerPromptHistory",
+ "odysseusRecallIndex",
+ ):
+ assert marker not in app_js, (
+ f"static/app.js reintroduces prompt recall ({marker!r}); "
+ "it belongs to static/js/composerArrowUpRecall.js alone"
+ )
diff --git a/tests/test_document_routes_shim.py b/tests/test_document_routes_shim.py
new file mode 100644
index 000000000..68d049a62
--- /dev/null
+++ b/tests/test_document_routes_shim.py
@@ -0,0 +1,29 @@
+"""Regression test for the document route shim (slice 2m, #4082/#4071).
+
+The backward-compat shims at ``routes/document_routes.py`` and
+``routes/document_helpers.py`` use ``sys.modules`` replacement so the legacy
+import paths and the canonical ``routes.document.*`` paths resolve to the
+*same* module objects. This is required because multiple tests do
+``import routes.document_routes as droutes`` followed by
+``droutes.SessionLocal = ...`` / ``monkeypatch.setattr(droutes, ...)`` and
+``sys.modules.pop("routes.document_helpers")`` + re-import — for those to
+take effect at runtime, the legacy and canonical module objects must be
+identical.
+"""
+
+import importlib
+
+import routes.document_routes as _shim_routes # noqa: F401
+import routes.document_helpers as _shim_helpers # noqa: F401
+
+
+def test_legacy_and_canonical_routes_are_same_object():
+ legacy = importlib.import_module("routes.document_routes")
+ canonical = importlib.import_module("routes.document.document_routes")
+ assert legacy is canonical
+
+
+def test_legacy_and_canonical_helpers_are_same_object():
+ legacy = importlib.import_module("routes.document_helpers")
+ canonical = importlib.import_module("routes.document.document_helpers")
+ assert legacy is canonical
diff --git a/tests/test_email_oauth_connect_smtp_security.py b/tests/test_email_oauth_connect_smtp_security.py
new file mode 100644
index 000000000..21c4224a6
--- /dev/null
+++ b/tests/test_email_oauth_connect_smtp_security.py
@@ -0,0 +1,15 @@
+"""Regression coverage for SMTP security saved before Google OAuth."""
+
+from pathlib import Path
+
+
+_REPO = Path(__file__).resolve().parents[1]
+
+
+def test_email_tab_oauth_connect_persists_selected_smtp_security():
+ source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
+ start = source.index("el('eaf-oauth-btn').addEventListener")
+ handler_body = source[start:source.index("if (!body.name)", start)]
+
+ assert "smtp_security: el('eaf-smtp-security').value" in handler_body
+ assert "display_name: el('eaf-display-name').value.trim()" in handler_body
diff --git a/tests/test_email_oauth_settings_redirect.py b/tests/test_email_oauth_settings_redirect.py
new file mode 100644
index 000000000..f7d588132
--- /dev/null
+++ b/tests/test_email_oauth_settings_redirect.py
@@ -0,0 +1,19 @@
+"""Regression coverage for the settings UI after Google OAuth redirects."""
+
+from pathlib import Path
+
+
+_REPO = Path(__file__).resolve().parents[1]
+
+
+def test_oauth_redirect_uses_the_module_local_settings_api():
+ source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
+ handler = source[
+ source.index("(function _handleOauthRedirect"):
+ source.index("const settingsModule =")
+ ]
+
+ assert "open('integrations');" in handler
+ assert "window.settingsModule" not in handler
+ assert "window.__odysseusAppStarted" not in handler
+ assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
diff --git a/tests/test_email_summary_error_ui_js.py b/tests/test_email_summary_error_ui_js.py
new file mode 100644
index 000000000..1afc3bec9
--- /dev/null
+++ b/tests/test_email_summary_error_ui_js.py
@@ -0,0 +1,52 @@
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO = Path(__file__).resolve().parent.parent
+_UTILS = (_REPO / "static" / "js" / "emailLibrary" / "utils.js").as_posix()
+_HAS_NODE = shutil.which("node") is not None
+
+pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+
+
+def test_email_summary_renderer_ignores_untrusted_provider_error_text():
+ secret = (
+ "endpoint=https://private.example.internal/v1 provider=ollama "
+ "model=private-model response_body=private-response "
+ "Authorization: Bearer token-secret-value"
+ )
+ script = f"""
+ import {{ _renderEmailSummaryError }} from '{_UTILS}';
+ const host = {{
+ ownerDocument: {{
+ createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
+ }},
+ replaceChildren(node) {{ this.child = node; }},
+ }};
+ _renderEmailSummaryError(host, {{
+ error_code: 'email_summary_unavailable',
+ error: {json.dumps(secret)},
+ }});
+ console.log(JSON.stringify({{
+ text: host.child.textContent,
+ color: host.child.style.color,
+ }}));
+ """
+
+ proc = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=str(_REPO),
+ timeout=30,
+ )
+
+ assert proc.returncode == 0, proc.stderr
+ rendered = json.loads(proc.stdout)
+ assert rendered == {"text": "Failed to summarize", "color": "var(--red)"}
+ assert secret not in proc.stdout
diff --git a/tests/test_email_summary_llm.py b/tests/test_email_summary_llm.py
new file mode 100644
index 000000000..b0ab7b3be
--- /dev/null
+++ b/tests/test_email_summary_llm.py
@@ -0,0 +1,406 @@
+import asyncio
+import json
+import logging
+import os
+import sqlite3
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+
+_TMP_DATA = Path(tempfile.mkdtemp(prefix="odysseus-email-summary-"))
+os.environ.setdefault("DATA_DIR", str(_TMP_DATA))
+os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP_DATA / 'app.db'}")
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+
+def _route_endpoint(router, path: str, method: str):
+ method = method.upper()
+ for route in router.routes:
+ if route.path == path and method in getattr(route, "methods", set()):
+ return route.endpoint
+ raise AssertionError(f"route not found: {method} {path}")
+
+
+@pytest.mark.asyncio
+async def test_generate_email_summary_uses_shared_llm_adapter(monkeypatch):
+ import routes.email_helpers as email_helpers
+ import src.llm_core as llm_core
+
+ calls = {}
+
+ async def fake_llm_call_async(url, model, messages, **kwargs):
+ calls["url"] = url
+ calls["model"] = model
+ calls["messages"] = messages
+ calls["kwargs"] = kwargs
+ return "thinking before marker\n<<>>\n- Pay the invoice by Friday.\n<<>>"
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
+
+ summary = await email_helpers._generate_email_summary(
+ url="https://chatgpt.com/backend-api/codex/responses",
+ model="gpt-5.5",
+ sender="Billing ",
+ subject="Invoice due",
+ body_for_llm="Please pay invoice 123 by Friday.",
+ headers={"Authorization": "Bearer test"},
+ max_tokens=1234,
+ timeout=45,
+ )
+
+ assert summary == "- Pay the invoice by Friday."
+ assert calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
+ assert calls["model"] == "gpt-5.5"
+ assert calls["kwargs"]["headers"] == {"Authorization": "Bearer test"}
+ assert calls["kwargs"]["temperature"] == 0.3
+ assert calls["kwargs"]["max_tokens"] == 1234
+ assert calls["kwargs"]["timeout"] == 45
+ assert calls["kwargs"]["workload"] == "foreground"
+ assert calls["messages"][0]["role"] == "system"
+ assert calls["messages"][1]["role"] == "user"
+
+
+@pytest.mark.asyncio
+async def test_scheduled_email_summary_uses_background_fallback_chain(monkeypatch):
+ import routes.email_helpers as email_helpers
+ import src.llm_core as llm_core
+ import src.task_endpoint as task_endpoint
+
+ candidates = [
+ ("http://primary.invalid/v1", "primary-model", {"X-Candidate": "primary"}),
+ ("http://fallback.invalid/v1", "fallback-model", {"X-Candidate": "fallback"}),
+ ]
+ resolve_calls = []
+ wait_calls = []
+ llm_calls = []
+
+ def fake_resolve_task_candidates(**kwargs):
+ resolve_calls.append(kwargs)
+ return candidates
+
+ async def fake_wait_for_interactive_quiet(label):
+ wait_calls.append(label)
+ return False
+
+ async def fake_llm_call_async(url, model, messages, **kwargs):
+ llm_calls.append((url, model, messages, kwargs))
+ if model == "primary-model":
+ raise RuntimeError("primary unavailable")
+ return "<<>>\n- Used the fallback model.\n<<>>"
+
+ monkeypatch.setattr(task_endpoint, "resolve_task_candidates", fake_resolve_task_candidates)
+ monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
+
+ summary = await email_helpers._generate_scheduled_email_summary(
+ url="http://caller-fallback.invalid/v1",
+ model="caller-fallback-model",
+ sender="Sender ",
+ subject="Scheduled subject",
+ body_for_llm="Please summarize this scheduled email.",
+ headers={"Authorization": "Bearer test"},
+ owner="alice",
+ max_tokens=321,
+ timeout=54,
+ )
+
+ assert summary == "- Used the fallback model."
+ assert resolve_calls == [{
+ "fallback_url": "http://caller-fallback.invalid/v1",
+ "fallback_model": "caller-fallback-model",
+ "fallback_headers": {"Authorization": "Bearer test"},
+ "owner": "alice",
+ }]
+ assert wait_calls == ["background task LLM"]
+ assert [call[1] for call in llm_calls] == ["primary-model", "fallback-model"]
+ assert all(call[3]["workload"] == "background" for call in llm_calls)
+ assert all(call[3]["max_tokens"] == 321 for call in llm_calls)
+ assert all(call[3]["timeout"] == 54 for call in llm_calls)
+
+
+@pytest.mark.asyncio
+async def test_scheduled_local_summary_is_preempted_by_foreground_call(monkeypatch):
+ import routes.email_helpers as email_helpers
+ import src.llm_core as llm_core
+ import src.task_endpoint as task_endpoint
+
+ local_url = "http://127.0.0.1:11434/v1/chat/completions"
+ background_started = asyncio.Event()
+ never_release = asyncio.Event()
+ observed_workloads = []
+
+ monkeypatch.setenv("ODYSSEUS_LOCAL_MODEL_GATE", "true")
+ monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
+ monkeypatch.setattr(llm_core, "_LOCAL_MODEL_LOCK", asyncio.Lock())
+ monkeypatch.setattr(llm_core, "_LOCAL_MODEL_CURRENT", {})
+ monkeypatch.setattr(llm_core, "_LOCAL_MODEL_WAITING_FOREGROUND", 0)
+ monkeypatch.setattr(
+ task_endpoint,
+ "resolve_task_candidates",
+ lambda **_kwargs: [(local_url, "scheduled-model", {})],
+ )
+
+ async def fake_wait_for_interactive_quiet(_label):
+ return False
+
+ async def gated_llm_call(url, model, messages, **kwargs):
+ assert messages
+ workload = kwargs.get("workload")
+ observed_workloads.append(workload)
+ async with llm_core._local_model_slot(url, model, workload=workload):
+ background_started.set()
+ await never_release.wait()
+ return "unreachable"
+
+ monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
+ monkeypatch.setattr(llm_core, "llm_call_async", gated_llm_call)
+
+ background_task = asyncio.create_task(email_helpers._generate_scheduled_email_summary(
+ url=local_url,
+ model="scheduled-model",
+ sender="Sender",
+ subject="Scheduled",
+ body_for_llm="Scheduled body",
+ owner="alice",
+ ))
+ foreground_task = None
+ try:
+ await asyncio.wait_for(background_started.wait(), timeout=1)
+
+ async def run_foreground():
+ async with llm_core._local_model_slot(
+ local_url,
+ "interactive-model",
+ workload="foreground",
+ ):
+ return True
+
+ foreground_task = asyncio.create_task(run_foreground())
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(background_task, timeout=1)
+ assert await asyncio.wait_for(foreground_task, timeout=1) is True
+ assert observed_workloads == ["background"]
+ finally:
+ for task in (background_task, foreground_task):
+ if task is not None and not task.done():
+ task.cancel()
+
+
+@pytest.mark.asyncio
+async def test_manual_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
+ import routes.email_helpers as email_helpers
+ import routes.email_routes as email_routes
+ import src.endpoint_resolver as endpoint_resolver
+
+ db_path = tmp_path / "scheduled_emails.db"
+ monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
+ monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
+ email_helpers._init_scheduled_db()
+
+ resolve_calls = []
+
+ def fake_resolve_endpoint(kind, owner=None):
+ resolve_calls.append((kind, owner))
+ assert kind == "utility"
+ assert owner == "alice"
+ return (
+ "https://chatgpt.com/backend-api/codex/responses",
+ "gpt-5.5",
+ {"Authorization": "Bearer test"},
+ )
+
+ helper_calls = {}
+
+ async def fake_generate_email_summary(**kwargs):
+ helper_calls.update(kwargs)
+ return "- Manual summary"
+
+ monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
+ monkeypatch.setattr(email_routes, "_generate_email_summary", fake_generate_email_summary)
+
+ router = email_routes.setup_email_routes()
+ summarize = _route_endpoint(router, "/api/email/summarize", "POST")
+
+ result = await summarize(
+ {
+ "body": "This is a long enough email body for manual summary.",
+ "subject": "Manual subject",
+ "from": "Sender ",
+ "message_id": "",
+ "folder": "INBOX",
+ },
+ owner="alice",
+ )
+
+ assert result == {
+ "success": True,
+ "summary": "- Manual summary",
+ "model_used": "gpt-5.5",
+ }
+ assert resolve_calls == [("utility", "alice")]
+ assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
+ assert helper_calls["model"] == "gpt-5.5"
+ assert helper_calls["headers"]["Authorization"] == "Bearer test"
+ assert helper_calls["headers"]["Content-Type"] == "application/json"
+
+ conn = sqlite3.connect(db_path)
+ try:
+ row = conn.execute(
+ "SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
+ ("",),
+ ).fetchone()
+ finally:
+ conn.close()
+ assert row == ("alice", "- Manual summary", "gpt-5.5")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("exception_kind", ["http", "runtime"])
+async def test_manual_email_summary_never_exposes_provider_exception(
+ monkeypatch,
+ caplog,
+ exception_kind,
+):
+ from fastapi import HTTPException
+ import routes.email_routes as email_routes
+ import src.endpoint_resolver as endpoint_resolver
+
+ secret_detail = (
+ "endpoint=https://private.example.internal/v1 provider=ollama "
+ "model=private-model response_body=private-response "
+ "Authorization: Bearer token-secret-value"
+ )
+
+ def fake_resolve_endpoint(kind, owner=None):
+ assert kind == "utility"
+ assert owner == "alice"
+ return (
+ "https://private.example.internal/v1",
+ "private-model",
+ {"Authorization": "Bearer token-secret-value"},
+ )
+
+ async def fail_summary(**_kwargs):
+ if exception_kind == "http":
+ raise HTTPException(status_code=502, detail=secret_detail)
+ raise RuntimeError(secret_detail)
+
+ monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
+ monkeypatch.setattr(email_routes, "_generate_email_summary", fail_summary)
+ caplog.set_level(logging.WARNING, logger=email_routes.__name__)
+
+ router = email_routes.setup_email_routes()
+ summarize = _route_endpoint(router, "/api/email/summarize", "POST")
+ result = await summarize(
+ {
+ "body": "This email body is long enough to summarize.",
+ "subject": "Sensitive provider failure",
+ "from": "Sender ",
+ },
+ owner="alice",
+ )
+
+ assert result == {
+ "success": False,
+ "error": "Failed to summarize",
+ "error_code": "email_summary_unavailable",
+ }
+ exposed = json.dumps(result) + caplog.text
+ for marker in (
+ "private.example.internal",
+ "ollama",
+ "private-model",
+ "private-response",
+ "token-secret-value",
+ ):
+ assert marker not in exposed
+ assert f"type={'HTTPException' if exception_kind == 'http' else 'RuntimeError'}" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_scheduled_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
+ import routes.email_helpers as email_helpers
+ import routes.email_pollers as email_pollers
+
+ db_path = tmp_path / "scheduled_emails.db"
+ monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
+ monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
+ email_helpers._init_scheduled_db()
+
+ raw_email = (
+ b"From: Sender \r\n"
+ b"To: Alice \r\n"
+ b"Subject: Scheduled subject\r\n"
+ b"Message-ID: \r\n"
+ b"Date: Tue, 01 Jan 2026 12:00:00 +0000\r\n"
+ b"Content-Type: text/plain; charset=utf-8\r\n"
+ b"\r\n"
+ + (b"Please review this scheduled summary email. " * 8)
+ )
+
+ class FakeImap:
+ def __init__(self):
+ self.logout_calls = 0
+
+ def select(self, _folder, readonly=True):
+ return "OK", []
+
+ def uid(self, command, *args):
+ if command == "SEARCH":
+ return "OK", [b"1"]
+ if command == "FETCH":
+ return "OK", [(b"1 (RFC822)", raw_email)]
+ raise AssertionError(f"unexpected uid command: {command!r} {args!r}")
+
+ def logout(self):
+ self.logout_calls += 1
+
+ fake_conn = FakeImap()
+
+ def fake_resolve_task_candidates(owner=None):
+ assert owner == "alice"
+ return [(
+ "https://chatgpt.com/backend-api/codex/responses",
+ "gpt-5.5",
+ {"Authorization": "Bearer test"},
+ )]
+
+ helper_calls = {}
+
+ async def fake_generate_email_summary(**kwargs):
+ helper_calls.update(kwargs)
+ return "- Scheduled summary"
+
+ monkeypatch.setattr(email_pollers, "_load_settings", lambda: {"email_auto_summarize": True})
+ monkeypatch.setattr(email_pollers, "_owner_for_email_account", lambda _account_id: "alice")
+ monkeypatch.setattr(email_pollers, "_imap_connect", lambda account_id=None, owner="": fake_conn)
+ monkeypatch.setattr(email_pollers, "_get_email_config", lambda account_id=None, owner="": {"from_address": "alice@example.com"})
+ monkeypatch.setattr(email_pollers, "resolve_task_candidates", fake_resolve_task_candidates)
+ monkeypatch.setattr(email_pollers, "_generate_scheduled_email_summary", fake_generate_email_summary)
+
+ result = await email_pollers._auto_summarize_pass_single(account_id="acct-alice")
+
+ assert "summarized 1" in result
+ assert "summary failed" not in result
+ assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
+ assert helper_calls["model"] == "gpt-5.5"
+ assert helper_calls["headers"]["Authorization"] == "Bearer test"
+ assert helper_calls["headers"]["Content-Type"] == "application/json"
+ assert helper_calls["owner"] == "alice"
+ assert fake_conn.logout_calls == 1
+
+ conn = sqlite3.connect(db_path)
+ try:
+ row = conn.execute(
+ "SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
+ ("",),
+ ).fetchone()
+ finally:
+ conn.close()
+ assert row == ("alice", "- Scheduled summary", "gpt-5.5")
diff --git a/tests/test_imap_mailbox_quoting.py b/tests/test_imap_mailbox_quoting.py
index 7c5bb1645..636270a56 100644
--- a/tests/test_imap_mailbox_quoting.py
+++ b/tests/test_imap_mailbox_quoting.py
@@ -87,7 +87,7 @@ def test_known_imap_mailbox_call_sites_are_quoted():
assert "conn.select(sent_name" not in pollers
assert "imap.append(sent_folder" not in pollers
- document_routes = Path("routes/document_routes.py").read_text()
+ document_routes = Path("routes/document/document_routes.py").read_text()
assert "conn.select(doc.source_email_folder" not in document_routes
diff --git a/tests/test_integration_api_call_ssrf.py b/tests/test_integration_api_call_ssrf.py
index 53dc671c5..f23cc40de 100644
--- a/tests/test_integration_api_call_ssrf.py
+++ b/tests/test_integration_api_call_ssrf.py
@@ -9,8 +9,13 @@ link-local/metadata is always rejected; RFC-1918/loopback only when
INTEGRATION_API_BLOCK_PRIVATE_IPS=true (LAN integrations are the primary
use case, so private stays allowed by default).
"""
+import asyncio
+import ipaddress
+import ssl
from unittest.mock import AsyncMock, MagicMock, patch
+import httpcore
+import httpx
import pytest
from src import integrations
@@ -97,3 +102,238 @@ async def test_private_base_url_allowed_by_default_blocked_with_knob(monkeypatch
assert result["exit_code"] == 1
assert "rejected" in result["error"].lower()
client.request.assert_not_called()
+
+
+async def _call_capturing_transport(base_url, path="/items"):
+ """Drive execute_api_call and return (result, transport) where transport is
+ the object passed to httpx.AsyncClient(transport=...)."""
+ resp = MagicMock()
+ resp.status_code = 200
+ resp.headers = {"content-type": "application/json"}
+ resp.json.return_value = {"ok": True}
+ resp.text = '{"ok": true}'
+
+ client = AsyncMock()
+ client.__aenter__ = AsyncMock(return_value=client)
+ client.__aexit__ = AsyncMock(return_value=None)
+ client.request = AsyncMock(return_value=resp)
+
+ captured = {}
+
+ def _fake_async_client(*args, **kwargs):
+ captured.update(kwargs)
+ return client
+
+ with (
+ patch.object(integrations, "_find_integration",
+ return_value=_integration(base_url)),
+ patch("httpx.AsyncClient", side_effect=_fake_async_client),
+ ):
+ result = await integrations.execute_api_call("test_integ", "GET", path)
+ return result, captured.get("transport"), client
+
+
+@pytest.mark.asyncio
+async def test_connection_is_pinned_to_the_validated_ip(monkeypatch):
+ """DNS-rebinding defense: the guard resolves the host once to a benign
+ public IP, and the request must be pinned to *that* IP so a host that
+ rebinds to the metadata range at connect time can't be reached with the
+ integration's auth headers. Static resolution passing the guard is not
+ enough — a plain client would re-resolve at connect."""
+ monkeypatch.setattr("src.url_safety._default_resolver",
+ lambda host: ["93.184.216.34"])
+ result, transport, client = await _call_capturing_transport(
+ "http://rebinding.attacker.example")
+
+ assert result.get("exit_code") == 0
+ client.request.assert_called_once()
+ assert isinstance(transport, integrations._PinnedAsyncTransport)
+ assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
+
+
+@pytest.mark.asyncio
+async def test_pin_carries_the_whole_validated_ip_set(monkeypatch):
+ """When a host resolves to several records the transport keeps all of them
+ (check_outbound_url validated every one), in resolver order, so it can fall
+ back past a dead first address instead of failing the whole call."""
+ monkeypatch.setattr("src.url_safety._default_resolver",
+ lambda host: ["93.184.216.34", "198.51.100.7"])
+ result, transport, _ = await _call_capturing_transport("http://multi.example")
+
+ assert result.get("exit_code") == 0
+ assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34", "198.51.100.7"]
+
+
+class _FakeStream:
+ """Stand-in for the connected socket the real backend returns."""
+
+
+class _RecordingBackend:
+ """Fake httpcore backend: connect_tcp fails for the addresses in `dead`
+ and succeeds for the rest, recording the order it was asked to connect."""
+
+ def __init__(self, dead):
+ self.dead = set(dead)
+ self.attempts = []
+
+ async def connect_tcp(self, host, port, timeout=None, local_address=None,
+ socket_options=None):
+ self.attempts.append((host, timeout))
+ if host in self.dead:
+ raise httpcore.ConnectError(f"connection refused: {host}")
+ return _FakeStream()
+
+
+def _pinned_backend(ips, dead):
+ """A _PinnedAsyncBackend whose underlying connect is the recording fake."""
+ backend = integrations._PinnedAsyncBackend(ips)
+ backend._real = _RecordingBackend(dead)
+ return backend
+
+
+@pytest.mark.asyncio
+async def test_connect_falls_back_from_dead_first_to_live_second():
+ """first-dead / second-live: the pinned backend must try the next validated
+ address when the first refuses, rather than surfacing the failure. It also
+ ignores the `host` httpcore passes (the original hostname) and connects to
+ the pinned IPs, which is what keeps TLS SNI / Host on the real hostname."""
+ ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
+ backend = _pinned_backend(ips, dead={"203.0.113.10"})
+
+ stream = await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
+
+ assert isinstance(stream, _FakeStream)
+ # Tried the dead address first, then the live one — never the hostname.
+ assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
+ # Fallback shared one budget: the second attempt got the time left, not a fresh 5s.
+ assert backend._real.attempts[1][1] <= 5.0
+
+
+@pytest.mark.asyncio
+async def test_connect_raises_when_every_validated_address_is_dead():
+ ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
+ backend = _pinned_backend(ips, dead={"203.0.113.10", "198.51.100.7"})
+
+ with pytest.raises(httpcore.ConnectError):
+ await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
+ assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
+
+
+@pytest.mark.asyncio
+async def test_pinned_transport_reuses_httpx_ca_trust(monkeypatch):
+ """TLS trust must come from the same builder the default httpx client uses
+ (certifi + SSL_CERT_FILE / SSL_CERT_DIR via trust_env), not from
+ ssl.create_default_context()'s system roots — otherwise chains that verified
+ under the old default client can silently stop verifying."""
+ sentinel = ssl.create_default_context()
+ calls = []
+
+ def _fake_create(*args, **kwargs):
+ calls.append(kwargs)
+ return sentinel
+
+ monkeypatch.setattr(httpx, "create_ssl_context", _fake_create)
+ transport = integrations._PinnedAsyncTransport([ipaddress.ip_address("93.184.216.34")])
+ try:
+ assert calls, "transport did not build its context via httpx.create_ssl_context"
+ assert transport._pool._ssl_context is sentinel
+ finally:
+ await transport.aclose()
+
+
+@pytest.mark.asyncio
+async def test_real_socket_falls_back_from_dead_first_to_live_second():
+ """End-to-end over real loopback sockets: pin [127.0.0.2 (nothing
+ listening), 127.0.0.1 (live)], and the request must succeed by falling back
+ to the second address while the Host header stays the original hostname —
+ i.e. only the socket destination moved, vhost/SNI routing did not."""
+ captured = {}
+
+ async def handle(reader, writer):
+ request = await reader.read(4096)
+ for line in request.split(b"\r\n"):
+ if line.lower().startswith(b"host:"):
+ captured["host"] = line.split(b":", 1)[1].strip().decode()
+ writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi")
+ await writer.drain()
+ writer.close()
+
+ server = await asyncio.start_server(handle, "127.0.0.1", 0)
+ port = server.sockets[0].getsockname()[1]
+ async with server:
+ await server.start_serving()
+ transport = integrations._PinnedAsyncTransport(
+ [ipaddress.ip_address("127.0.0.2"), ipaddress.ip_address("127.0.0.1")]
+ )
+ try:
+ async with httpx.AsyncClient(transport=transport) as client:
+ resp = await client.get(f"http://pinned.example:{port}/health")
+ finally:
+ await transport.aclose()
+
+ assert resp.status_code == 200
+ assert resp.text == "hi"
+ assert captured.get("host") == f"pinned.example:{port}"
+
+
+@pytest.mark.asyncio
+async def test_ip_literal_base_url_still_pins_and_is_not_rejected():
+ """A base_url that is already an IP has nothing to rebind, but it must not
+ trip the "did not resolve" guard either.
+
+ check_outbound_url resolves even a literal (getaddrinfo returns the address
+ itself), so the captured list is populated and the pin is a no-op rather
+ than a rejection. Uses the real resolver on purpose — no monkeypatch — so
+ this would catch the fail-closed branch firing on a literal.
+ """
+ result, transport, client = await _call_capturing_transport(
+ "http://93.184.216.34")
+
+ assert result.get("exit_code") == 0
+ assert isinstance(transport, integrations._PinnedAsyncTransport)
+ assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
+
+
+@pytest.mark.asyncio
+async def test_ipv6_base_url_pins_every_validated_address(monkeypatch):
+ """IPv6 goes down the same path as v4.
+
+ Resolution is stubbed rather than using a literal so this doesn't depend on
+ the runner having IPv6 configured.
+ """
+ v6 = "2606:2800:220:1:248:1893:25c8:1946"
+ monkeypatch.setattr("src.url_safety._default_resolver", lambda host: [v6])
+ result, transport, client = await _call_capturing_transport("http://v6.example")
+
+ assert result.get("exit_code") == 0
+ assert isinstance(transport, integrations._PinnedAsyncTransport)
+ assert [str(ip) for ip in transport._pinned_ips] == [v6]
+
+
+def test_validated_ips_strips_zone_id_and_drops_junk():
+ """getaddrinfo can hand back a scoped v6 address like 'fe80::1%eth0'."""
+ got = integrations._validated_ips(
+ ["93.184.216.34", "fe80::1%eth0", "not-an-ip", None, "2001:db8::5"]
+ )
+ assert [str(ip) for ip in got] == ["93.184.216.34", "fe80::1", "2001:db8::5"]
+
+
+def test_validated_ips_deduplicates_repeated_addresses():
+ """The resolver is getaddrinfo(host, None) with no socktype filter, so glibc
+ returns one record per socktype and a single-homed host arrives three times
+ over. Duplicates must collapse (first-seen order kept) or the connect
+ fallback wastes its shared deadline retrying one dead address."""
+ got = integrations._validated_ips(
+ ["93.184.216.34", "93.184.216.34", "93.184.216.34"]
+ )
+ assert [str(ip) for ip in got] == ["93.184.216.34"]
+
+ # Order is first-seen, and distinct addresses all survive.
+ got = integrations._validated_ips(
+ ["198.51.100.7", "93.184.216.34", "198.51.100.7", "2001:db8::5"]
+ )
+ assert [str(ip) for ip in got] == ["198.51.100.7", "93.184.216.34", "2001:db8::5"]
+
+ # A zone-id variant is the same address once stripped, so it collapses too.
+ got = integrations._validated_ips(["fe80::1%eth0", "fe80::1%eth1", "fe80::1"])
+ assert [str(ip) for ip in got] == ["fe80::1"]
diff --git a/tests/test_integrations_api_call_truncation.py b/tests/test_integrations_api_call_truncation.py
index bf1ec7d05..a0ad61b4a 100644
--- a/tests/test_integrations_api_call_truncation.py
+++ b/tests/test_integrations_api_call_truncation.py
@@ -83,9 +83,10 @@ async def _call(json_data, status=200):
with (
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
patch("httpx.AsyncClient", return_value=mock_client),
- # api.example.com doesn't resolve; the SSRF guard would fail closed.
- # These tests are about truncation, so stub the guard open.
- patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
+ # api.example.com doesn't resolve. Point the resolver at a public
+ # address instead of stubbing the guard open, so the real check (and
+ # the connect-IP pinning that reads its result) still runs.
+ patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
):
return await integrations.execute_api_call("test_integ", "GET", "/items")
@@ -101,9 +102,10 @@ async def _call_with_integration(integration, path="/items"):
with (
patch.object(integrations, "_find_integration", return_value=integration),
patch("httpx.AsyncClient", return_value=mock_client),
- # api.example.com doesn't resolve; the SSRF guard would fail closed.
- # These tests are about URL joining, so stub the guard open.
- patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
+ # api.example.com doesn't resolve. Point the resolver at a public
+ # address instead of stubbing the guard open, so the real check (and
+ # the connect-IP pinning that reads its result) still runs.
+ patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
):
result = await integrations.execute_api_call("test_integ", "GET", path)
return result, mock_client
diff --git a/tests/test_issue_description_check.py b/tests/test_issue_description_check.py
new file mode 100644
index 000000000..196f21cfc
--- /dev/null
+++ b/tests/test_issue_description_check.py
@@ -0,0 +1,86 @@
+"""Regression coverage for issue-description label lifecycle events."""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO = Path(__file__).resolve().parent.parent
+_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
+_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
+pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
+
+
+def _run_closed_issue(action):
+ harness = r"""
+const checkIssueDescription = require(process.argv[1]);
+const action = process.argv[2];
+const calls = [];
+const unexpected = (name) => async () => {
+ throw new Error(`${name} should not be called for a closed issue`);
+};
+
+const github = {
+ rest: {
+ issues: {
+ removeLabel: async (params) => calls.push({ method: 'removeLabel', params }),
+ getLabel: unexpected('getLabel'),
+ addLabels: unexpected('addLabels'),
+ listComments: unexpected('listComments'),
+ createComment: unexpected('createComment'),
+ updateComment: unexpected('updateComment'),
+ deleteComment: unexpected('deleteComment'),
+ },
+ },
+};
+const context = {
+ payload: {
+ action,
+ issue: { number: 42, state: 'closed', body: '', labels: [] },
+ },
+ repo: { owner: 'odysseus-dev', repo: 'odysseus' },
+};
+const core = {
+ warning: unexpected('core.warning'),
+ setFailed: unexpected('core.setFailed'),
+};
+
+checkIssueDescription({ github, context, core })
+ .then(() => process.stdout.write(JSON.stringify(calls)))
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ });
+"""
+ proc = subprocess.run(
+ ["node", "-e", harness, str(_CHECKER), action],
+ capture_output=True,
+ text=True,
+ cwd=str(_REPO),
+ timeout=30,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_workflow_handles_issue_closures():
+ workflow = _WORKFLOW.read_text()
+ assert "types: [opened, edited, reopened, closed]" in workflow
+
+
+@pytest.mark.parametrize("action", ["closed", "edited"])
+def test_closed_issue_only_drops_ready_for_review(action):
+ assert _run_closed_issue(action) == [
+ {
+ "method": "removeLabel",
+ "params": {
+ "owner": "odysseus-dev",
+ "repo": "odysseus",
+ "issue_number": 42,
+ "name": "ready for review",
+ },
+ }
+ ]
diff --git a/tests/test_llm_core_anthropic_temp_omit.py b/tests/test_llm_core_anthropic_temp_omit.py
index 2274f1dc9..f7d26aef0 100644
--- a/tests/test_llm_core_anthropic_temp_omit.py
+++ b/tests/test_llm_core_anthropic_temp_omit.py
@@ -29,6 +29,13 @@ from src.llm_core import _anthropic_rejects_temperature, _build_anthropic_payloa
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
"claude-opus-4-10", # future minor still >= 4.7
"claude-opus-5-0", # future major
+ # Major-only ids: a missing minor reads as `.0`, so these are >= 4.7 too
+ # (issue #5753). Before the fix the version pattern required a minor, so
+ # these fell through to "accepts temperature" and every call 400'd.
+ "claude-opus-5",
+ "claude-opus-5-20260101", # major-only + dated snapshot
+ "anthropic/claude-opus-5", # major-only behind a provider prefix
+ "claude-opus-6", # future major-only
],
)
def test_opus_47_plus_rejects_temperature(model):
@@ -48,7 +55,10 @@ def test_opus_47_plus_rejects_temperature(model):
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
"claude-sonnet-4-6",
"claude-3-5-sonnet",
- "claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
+ "claude-3-opus-20240229", # legacy Claude 3 Opus — date directly after
+ # "opus-", so the major must not swallow it as version 20240229 (that is
+ # what makes capping the major at 1-2 digits necessary once the minor
+ # became optional in #5753).
"claude-haiku-4-5",
"claude-x",
"octopus-4-8", # "opus" only as a substring of another word — must not match
@@ -87,6 +97,20 @@ def test_payload_keeps_temperature_for_older_models():
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
+def test_payload_omits_temperature_for_major_only_opus_5():
+ # Issue #5753: the scheduled-task path calls stream_agent_loop() without a
+ # temperature and inherits its 0.3 default, so `claude-opus-5` 400'd on every
+ # run and surfaced as "the model returned an empty response". Interactive chat
+ # leaves temperature None and never hit it.
+ assert "temperature" not in _payload("claude-opus-5", 0.3)
+
+
+def test_payload_keeps_temperature_for_legacy_claude_3_opus():
+ # Guards the major-digit cap: `opus-20240229` must not parse as version
+ # 20240229, or Claude 3 Opus would silently lose the caller's temperature.
+ assert _payload("claude-3-opus-20240229", 0.5)["temperature"] == 0.5
+
+
def test_payload_keeps_temperature_for_dated_opus_4_0():
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
diff --git a/tests/test_manage_skills_action_required.py b/tests/test_manage_skills_action_required.py
new file mode 100644
index 000000000..4efae8026
--- /dev/null
+++ b/tests/test_manage_skills_action_required.py
@@ -0,0 +1,24 @@
+import json
+
+import pytest
+
+from src.tools.system import do_manage_skills
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {},
+ {"action": ""},
+ {"action": " "},
+ {"name": "demo", "description": "x", "procedure": ["step"]},
+ ],
+)
+async def test_manage_skills_requires_action(payload):
+ result = await do_manage_skills(json.dumps(payload), owner="test")
+
+ assert result == {
+ "error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)",
+ "exit_code": 1,
+ }
diff --git a/tests/test_markdown_rendering_js.py b/tests/test_markdown_rendering_js.py
index 2ffe8914f..536789b89 100644
--- a/tests/test_markdown_rendering_js.py
+++ b/tests/test_markdown_rendering_js.py
@@ -214,6 +214,50 @@ def test_inline_code_content_is_html_escaped(node_available):
assert "" not in html
+def test_fenced_code_keeps_dollar_ampersand(node_available):
+ # Issue #5663: the block-restore pass used a string replacement, so `$&` in a
+ # restored block was read as "the matched text" and re-inserted the
+ # placeholder. `perl -pe 's/world/$& again/'` rendered as
+ # "s/world/___CODE_BLOCK_0___amp; again/" — the trailing "amp;" is the orphan
+ # left behind after `$&` consumed the `$&` of the escaped `$&`.
+ html = _run_markdown_case(
+ "```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
+ )
+
+ assert "___CODE_BLOCK_" not in html
+ assert "s/world/$& again/" in html
+ assert "amp; again" not in html.replace("$& again", "")
+
+
+def test_fenced_code_keeps_dollar_backtick_and_quote(node_available):
+ # `` $` `` and `$'` splice the text before/after the placeholder into the
+ # block. Unlike `$&` these leave no placeholder behind — the characters just
+ # vanish — so assert the content survives verbatim.
+ html = _run_markdown_case("```sh\nsed \"s/$`/x/\" && sed \"s/$'/y/\"\n```")
+
+ assert "___CODE_BLOCK_" not in html
+ assert "s/$`/x/" in html
+ assert "s/$'/y/" in html
+
+
+def test_fenced_code_keeps_double_dollar(node_available):
+ # `$$` collapsed to a single `$` in the restored block.
+ html = _run_markdown_case('```sh\necho "$$USD and $$"\n```')
+
+ assert "$$USD and $$" in html
+
+
+def test_mermaid_block_keeps_dollar_ampersand(node_available):
+ # The mermaid restore site had the same hazard: a node label containing `$&`
+ # re-inserted the ___MERMAID_BLOCK_n___ placeholder into the diagram source,
+ # which then fails to parse. The math and allowed-HTML sites are fixed the
+ # same way; they need KaTeX/sanitizer conditions this harness doesn't set up.
+ html = _run_markdown_case('```mermaid\ngraph TD; A["$&"] --> B;\n```')
+
+ assert "___MERMAID_BLOCK_" not in html
+ assert "$&" in html
+
+
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
diff --git a/tests/test_mcp_dependency_compatibility.py b/tests/test_mcp_dependency_compatibility.py
new file mode 100644
index 000000000..9efefe4fe
--- /dev/null
+++ b/tests/test_mcp_dependency_compatibility.py
@@ -0,0 +1,15 @@
+"""Regression coverage for the built-in MCP servers' SDK compatibility line."""
+
+from pathlib import Path
+
+
+REQUIREMENTS = Path(__file__).resolve().parents[1] / "requirements.txt"
+
+
+def test_mcp_requirement_excludes_breaking_v2_sdk():
+ requirements = [
+ line.split("#", 1)[0].strip().replace(" ", "")
+ for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines()
+ ]
+
+ assert "mcp<2" in requirements
diff --git a/tests/test_memory_add_submit_regression.py b/tests/test_memory_add_submit_regression.py
new file mode 100644
index 000000000..450d63003
--- /dev/null
+++ b/tests/test_memory_add_submit_regression.py
@@ -0,0 +1,54 @@
+"""The Brain > Add Memory form must be submittable (#5828).
+
+The form previously had no submit button and relied on a deprecated
+``keypress`` listener for Enter, which is not guaranteed to fire on all
+platforms — leaving the form with no working submit path. Pins:
+
+- a visible, keyboard-accessible submit button next to the category select;
+- the button wired to ``memoryModule.addNewMemory()``;
+- Enter handled via ``keydown`` with ``preventDefault()`` (and no lingering
+ ``keypress`` handler on the input).
+"""
+from pathlib import Path
+
+APP_JS = Path("static/app.js")
+INDEX_HTML = Path("static/index.html")
+
+
+def _add_memory_row(html):
+ start = html.index('id="new-memory-input"')
+ end = html.index("
", html.index('id="new-memory-add-btn"', start))
+ return html[start:end]
+
+
+def test_add_memory_form_renders_a_submit_button():
+ html = INDEX_HTML.read_text()
+ row = _add_memory_row(html)
+
+ assert 'id="new-memory-category"' in row, "button must sit in the same row as the form fields"
+ btn_start = row.index('id="new-memory-add-btn"')
+ btn_tag = row[row.rindex("", btn_start)]
+ assert 'type="button"' in btn_tag, "must not rely on implicit submit semantics"
+
+
+def _new_memory_wiring_block(source):
+ start = source.index("const newMemoryInput = el('new-memory-input');")
+ end = source.index("// Voice recording", start)
+ return source[start:end]
+
+
+def test_submit_button_is_wired_to_add_new_memory():
+ block = _new_memory_wiring_block(APP_JS.read_text())
+
+ assert "el('new-memory-add-btn')" in block
+ assert "addEventListener('click', () => memoryModule.addNewMemory())" in block
+
+
+def test_enter_uses_keydown_with_prevent_default():
+ block = _new_memory_wiring_block(APP_JS.read_text())
+
+ assert "addEventListener('keydown'" in block
+ assert "addEventListener('keypress'" not in block, "keypress is deprecated and unreliable for Enter"
+ assert "e.preventDefault();" in block
+ assert "!e.isComposing" in block, "IME composition must not submit the form"
+ assert "memoryModule.addNewMemory();" in block
diff --git a/tests/test_memory_extractor_vector_cross_tenant.py b/tests/test_memory_extractor_vector_cross_tenant.py
index 49702c17f..06ca31667 100644
--- a/tests/test_memory_extractor_vector_cross_tenant.py
+++ b/tests/test_memory_extractor_vector_cross_tenant.py
@@ -67,6 +67,12 @@ class FakeMemoryManager:
def load_all(self):
return list(self.rows)
+ def load_all_for_update(self):
+ # Mirrors the real MemoryManager: extraction is a read-modify-write and
+ # goes through the strict loader (#5673). A healthy store behaves the
+ # same as load_all.
+ return list(self.rows)
+
def load(self, owner=None):
return [r for r in self.rows if r.get("owner") == owner]
diff --git a/tests/test_memory_store_unreadable_no_wipe.py b/tests/test_memory_store_unreadable_no_wipe.py
new file mode 100644
index 000000000..4b9076065
--- /dev/null
+++ b/tests/test_memory_store_unreadable_no_wipe.py
@@ -0,0 +1,255 @@
+"""A memory store that cannot be READ must never be overwritten (issue #5673).
+
+`MemoryManager.save` is atomic, and the add/import/extract paths are all
+read-modify-write: load the whole store, append, save it back. `load_all`
+used to answer a *failed read* with `[]` — indistinguishable from "no
+memories" — so a failed read turned into
+
+ load_all() -> [] -> [].append(new) -> save([new])
+
+which atomically replaced the entire store with one entry.
+
+The trigger that actually bites is a store that is **readable but not
+parseable** — a truncated file, or one holding `{}` instead of `[]`. Nothing
+obstructs the write, so the request succeeds with HTTP 200 and every existing
+memory is destroyed silently. Truncation is reachable: `core/database.py`
+rewrites memory.json during migration with a plain `open(..., "w")` +
+`json.dump`, which is not atomic.
+
+A live exclusive lock is NOT the dangerous case: it blocks the read and the
+`os.replace` alike, so the save fails too and the store survives (verified
+end-to-end — clean dev returns 500 there and loses nothing).
+
+`load_all_for_update` is the strict loader those callers now use: it raises
+`MemoryStoreUnreadable` rather than reporting an empty store.
+"""
+
+import asyncio
+import builtins
+import json
+import os
+
+import pytest
+
+from src.memory import MemoryManager, MemoryStoreUnreadable
+
+_SEED = [
+ {"id": "m1", "text": "user prefers dark mode", "owner": "alice"},
+ {"id": "m2", "text": "user lives in Berlin", "owner": "alice"},
+ {"id": "m3", "text": "bob's cat is called Mila", "owner": "bob"},
+]
+
+
+def _seeded(tmp_path):
+ m = MemoryManager(str(tmp_path))
+ m.save([dict(e) for e in _SEED])
+ return m
+
+
+def _break_reads_of(monkeypatch, target, exc):
+ """Make open() raise `exc` for `target` only, leaving every other path alone."""
+ real_open = builtins.open
+
+ def fake_open(file, mode="r", *args, **kwargs):
+ if os.path.abspath(str(file)) == os.path.abspath(target) and "r" in mode:
+ raise exc
+ return real_open(file, mode, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "open", fake_open)
+
+
+# ── the strict loader signals, rather than reporting "empty" ──────────────
+
+def test_strict_load_raises_on_permission_error(tmp_path, monkeypatch):
+ m = _seeded(tmp_path)
+ _break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
+ with pytest.raises(MemoryStoreUnreadable):
+ m.load_all_for_update()
+
+
+def test_strict_load_raises_on_corrupt_json(tmp_path):
+ m = _seeded(tmp_path)
+ with open(m.memory_file, "w", encoding="utf-8") as f:
+ f.write('[{"id": "m1", "text": "truncated mid-writ')
+ with pytest.raises(MemoryStoreUnreadable):
+ m.load_all_for_update()
+
+
+def test_strict_load_raises_when_store_is_not_a_list(tmp_path):
+ # A file holding `{}` or `null` is not an empty store, it is a broken one.
+ m = _seeded(tmp_path)
+ with open(m.memory_file, "w", encoding="utf-8") as f:
+ json.dump({}, f)
+ with pytest.raises(MemoryStoreUnreadable):
+ m.load_all_for_update()
+
+
+def test_strict_load_returns_entries_when_healthy(tmp_path):
+ m = _seeded(tmp_path)
+ assert {e["id"] for e in m.load_all_for_update()} == {"m1", "m2", "m3"}
+
+
+def test_strict_load_returns_empty_when_file_genuinely_absent(tmp_path):
+ m = _seeded(tmp_path)
+ os.remove(m.memory_file)
+ # Absent is the one case that legitimately means "no memories yet".
+ assert m.load_all_for_update() == []
+
+
+# ── read paths stay lenient, so an unreadable store can't break chat ──────
+
+def test_read_path_still_degrades_to_empty(tmp_path, monkeypatch):
+ m = _seeded(tmp_path)
+ _break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
+ # Context injection / search must not raise; they just see nothing.
+ assert m.load_all() == []
+ assert m.load(owner="alice") == []
+
+
+# ── the actual #5673 regression: the store survives ───────────────────────
+
+def test_add_cycle_under_transient_read_error_does_not_wipe(tmp_path, monkeypatch):
+ """Mirrors routes/memory/memory_routes.py api_add_memory exactly."""
+ m = _seeded(tmp_path)
+ new_entry = m.add_entry("a brand new fact", owner="alice")
+
+ with monkeypatch.context() as mp:
+ _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
+ with pytest.raises(MemoryStoreUnreadable):
+ all_mem = m.load_all_for_update()
+ all_mem.append(new_entry)
+ m.save(all_mem)
+
+ # Reads work again; every original memory is still there and the file was
+ # never replaced by the single new entry.
+ assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
+
+
+def test_audit_merge_cannot_drop_other_tenants(tmp_path, monkeypatch):
+ """The audit path rebuilds the whole file from load_all + one owner's slice.
+
+ Reading [] there would save only the audited owner's entries and destroy
+ every other tenant's memories, so it has to fail closed too.
+ """
+ m = _seeded(tmp_path)
+ alice_slice = [e for e in _SEED if e["owner"] == "alice"]
+
+ with monkeypatch.context() as mp:
+ _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
+ with pytest.raises(MemoryStoreUnreadable):
+ all_entries = m.load_all_for_update()
+ others = [e for e in all_entries if e.get("owner") != "alice"]
+ m.save(alice_slice + others)
+
+ assert any(e["id"] == "m3" for e in m.load_all()), "bob's memory was destroyed"
+
+
+def test_uses_bump_skips_write_when_unreadable(tmp_path, monkeypatch):
+ m = _seeded(tmp_path)
+ with monkeypatch.context() as mp:
+ _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
+ m.increment_uses(["m1"]) # must not raise, must not write
+ assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
+
+
+def test_claim_ownerless_skips_write_when_unreadable(tmp_path, monkeypatch):
+ m = _seeded(tmp_path)
+ with monkeypatch.context() as mp:
+ _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
+ m.claim_ownerless("alice")
+ assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
+
+
+# ── the add sinks users actually reach ────────────────────────────────────
+#
+# The tests above replay the read-modify-write shape. These drive the real
+# entry points end to end, because those are what #5673 reports: "remember
+# that I prefer X" in ordinary chat (src/ai_interaction.py do_manage_memory,
+# routed from src/tool_execution.py) and the built-in memory MCP server
+# (mcp_servers/memory_server.py, registered in src/builtin_mcp.py).
+#
+# They use a truncated store rather than a read error on purpose: it reads
+# fine, so nothing stops the save, which is the case that silently destroyed
+# stores. The assertion is that the file is left byte-identical — still broken,
+# but still holding the user's memories, so it can be repaired by hand.
+
+
+def _truncated_store(tmp_path):
+ """Seed a store that reads back fine but no longer parses."""
+ m = _seeded(tmp_path)
+ good = json.dumps([dict(e) for e in _SEED], indent=2)
+ with open(m.memory_file, "w", encoding="utf-8") as f:
+ f.write(good[:good.rindex("]")]) # drop the closing bracket only
+ with open(m.memory_file, "rb") as f:
+ return m, f.read()
+
+
+def _on_disk(manager) -> bytes:
+ with open(manager.memory_file, "rb") as f:
+ return f.read()
+
+
+def test_agent_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
+ """src/ai_interaction.py do_manage_memory, action "add"."""
+ from src import ai_interaction
+
+ manager, before = _truncated_store(tmp_path)
+ monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
+ monkeypatch.setattr(ai_interaction, "_memory_vector", None)
+
+ result = asyncio.run(ai_interaction.do_manage_memory("add\nuser prefers tabs"))
+
+ assert _on_disk(manager) == before, "the unreadable store was overwritten"
+ assert b"m3" in _on_disk(manager)
+ assert "error" in result, "the add reported success over an unreadable store"
+
+
+def test_mcp_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
+ """mcp_servers/memory_server.py, action "add"."""
+ import mcp_servers.memory_server as memory_server
+
+ manager, before = _truncated_store(tmp_path)
+ monkeypatch.setattr(memory_server, "_memory_manager", manager)
+ monkeypatch.setattr(memory_server, "_memory_vector", None)
+ monkeypatch.setattr(memory_server, "_initialized", True)
+ for key in memory_server._OWNER_ENV_KEYS:
+ monkeypatch.delenv(key, raising=False)
+
+ result = asyncio.run(memory_server.call_tool(
+ "manage_memory", {"action": "add", "text": "user prefers tabs"}
+ ))
+
+ assert _on_disk(manager) == before, "the unreadable store was overwritten"
+ assert b"m3" in _on_disk(manager)
+ assert result[0].text.startswith("Error:")
+
+
+def test_native_provider_remember_does_not_overwrite_unreadable_store(tmp_path):
+ """src/memory_provider.py NativeMemoryProvider.remember.
+
+ Registered into app state in src/app_initializer.py but not yet consumed
+ outside tests, so this is the pattern held in place before it goes live.
+ """
+ from src.memory_provider import NativeMemoryProvider
+
+ manager, before = _truncated_store(tmp_path)
+ provider = NativeMemoryProvider(manager)
+
+ with pytest.raises(MemoryStoreUnreadable):
+ asyncio.run(provider.remember("user prefers tabs", owner="alice"))
+
+ assert _on_disk(manager) == before
+
+
+# ── the legacy memory.txt migration is preserved ──────────────────────────
+
+def test_corrupt_store_still_migrates_from_legacy_txt(tmp_path):
+ m = _seeded(tmp_path)
+ with open(m.memory_file, "w", encoding="utf-8") as f:
+ f.write("{ not json")
+ legacy = os.path.join(str(tmp_path), "memory.txt")
+ with open(legacy, "w", encoding="utf-8") as f:
+ f.write("recovered fact one\nrecovered fact two\n")
+
+ entries = m.load_all_for_update()
+ assert [e["text"] for e in entries] == ["recovered fact one", "recovered fact two"]
diff --git a/tests/test_model_helper_owner_scope.py b/tests/test_model_helper_owner_scope.py
index dafbad594..f48a1f7e2 100644
--- a/tests/test_model_helper_owner_scope.py
+++ b/tests/test_model_helper_owner_scope.py
@@ -14,7 +14,7 @@ def _function_source(path: str, name: str) -> str:
def test_document_ai_tidy_resolves_with_owner_scope():
- body = _function_source("routes/document_routes.py", "ai_tidy_documents")
+ body = _function_source("routes/document/document_routes.py", "ai_tidy_documents")
assert "resolve_task_endpoint(owner=user or None)" in body
assert 'resolve_endpoint("default", owner=user or None)' in body
diff --git a/tests/test_search_routes_shim.py b/tests/test_search_routes_shim.py
new file mode 100644
index 000000000..a8b278488
--- /dev/null
+++ b/tests/test_search_routes_shim.py
@@ -0,0 +1,11 @@
+"""Regression test for the search route shim (slice 2j, #4082/#4071)."""
+
+import importlib
+
+import routes.search_routes as _shim_search # noqa: F401
+
+
+def test_legacy_and_canonical_search_module_are_same_object():
+ legacy = importlib.import_module("routes.search_routes")
+ canonical = importlib.import_module("routes.search.search_routes")
+ assert legacy is canonical
diff --git a/tests/test_skill_format_timestamp.py b/tests/test_skill_format_timestamp.py
new file mode 100644
index 000000000..a9309bdc1
--- /dev/null
+++ b/tests/test_skill_format_timestamp.py
@@ -0,0 +1,58 @@
+"""Regression for issue #5697 — skill timestamps must not use ``datetime.utcnow()``.
+
+``_now_iso()`` builds the ``created`` value in skill frontmatter. ``utcnow()``
+returns a *naive* datetime and has been deprecated since Python 3.12, scheduled
+for removal. The replacement must stay timezone-aware while keeping the
+serialized ``YYYY-MM-DDTHH:MM:SSZ`` shape, so skill files written by older
+versions keep parsing.
+
+The UTC check matters on its own: a bare ``datetime.now()`` also produces the
+right shape, but emits local wall time, which would silently backdate or
+postdate skills for every user outside UTC.
+"""
+
+import os
+import re
+import time
+import warnings
+from datetime import datetime, timezone
+
+import pytest
+
+from services.memory.skill_format import _now_iso
+
+_ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
+
+
+def test_now_iso_keeps_serialized_shape():
+ assert _ISO_Z.match(_now_iso())
+
+
+def test_now_iso_emits_no_deprecation_warning():
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ _now_iso()
+ assert not [w for w in caught if issubclass(w.category, DeprecationWarning)]
+
+
+@pytest.mark.skipif(
+ not hasattr(time, "tzset"),
+ reason="time.tzset is unavailable on this platform",
+)
+def test_now_iso_is_utc_not_local_time():
+ """Pin UTC under a non-UTC local timezone, where the two visibly diverge."""
+ original_tz = os.environ.get("TZ")
+ os.environ["TZ"] = "Asia/Amman" # UTC+3, never UTC
+ time.tzset()
+ try:
+ emitted = datetime.strptime(_now_iso(), "%Y-%m-%dT%H:%M:%SZ").replace(
+ tzinfo=timezone.utc
+ )
+ drift = abs((emitted - datetime.now(timezone.utc)).total_seconds())
+ assert drift < 60, f"timestamp is {drift}s off UTC — local time leaked in"
+ finally:
+ if original_tz is None:
+ os.environ.pop("TZ", None)
+ else:
+ os.environ["TZ"] = original_tz
+ time.tzset()
diff --git a/tests/test_tool_parsing_bare_end_marker.py b/tests/test_tool_parsing_bare_end_marker.py
new file mode 100644
index 000000000..6167c8dde
--- /dev/null
+++ b/tests/test_tool_parsing_bare_end_marker.py
@@ -0,0 +1,96 @@
+"""Regression: the Qwen bare-marker scrub must not eat a lone `end` (#5547).
+
+`_QWEN_BARE_MARKER_RE` cleans Qwen turn markers that leak into content. Its
+`end` branch was `\\|?end\\|?` — both pipes optional — so it also matched a bare
+`end` surrounded by whitespace and replaced it with a space. Any message
+containing Ruby, Lua or shell code that closes a block with a lone `end` had
+those lines silently deleted, in the stored text and in the rendered message.
+
+Requiring at least one pipe keeps every real marker (`|end`, `end|`, `|end|`,
+`/|end|`) stripping as before. The same pattern is duplicated in
+static/js/chatRenderer.js, so the JS copy is checked here too — the two must
+not drift.
+"""
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
+from src.tool_parsing import strip_tool_blocks
+
+_REPO = Path(__file__).resolve().parent.parent
+_CHAT_RENDERER = _REPO / "static" / "js" / "chatRenderer.js"
+
+# Inputs that must survive untouched, and the substring that proves they did.
+KEPT = [
+ ("loop do\n puts \"yo\"\nend\n", "\nend"), # the reported Ruby case
+ ("if x then\nend", "\nend"),
+ ("function f()\nend\n", "\nend"),
+ ("a end b", "a end b"),
+ ("append end", "append end"),
+ ("END", "END"),
+ ("\nEnd\n", "End"),
+]
+
+# Real markers — at least one pipe, plus the role word — with the exact output
+# they must still produce. Asserted as equality rather than "marker not in out"
+# so narrowing the pattern can't pass by deleting more than it should.
+STRIPPED = [
+ ("a |end| b", "a b"),
+ ("a /|end| b", "a b"),
+ ("a |end b", "a b"),
+ ("a end| b", "a b"),
+ ("x assistant y", "x y"),
+]
+
+
+@pytest.mark.parametrize("text,kept", KEPT)
+def test_bare_end_survives_stripping(text, kept):
+ assert kept in strip_tool_blocks(text)
+
+
+@pytest.mark.parametrize("text,expected", STRIPPED)
+def test_piped_end_markers_are_still_stripped(text, expected):
+ assert strip_tool_blocks(text) == expected
+
+
+def test_bare_end_inside_a_fenced_block_survives():
+ """The scrub runs over the whole message, fenced regions included."""
+ out = strip_tool_blocks("Here:\n```ruby\nloop do\n puts 1\nend\n```\nDone.")
+ assert "\nend\n" in out
+
+
+def _js_bare_marker_regex_source():
+ src = _CHAT_RENDERER.read_text(encoding="utf-8")
+ m = re.search(r"^const QWEN_BARE_MARKER_RE = (/.*/[gimsuy]*);$", src, re.MULTILINE)
+ assert m, "QWEN_BARE_MARKER_RE literal not found in chatRenderer.js"
+ return m.group(1)
+
+
+def test_js_copy_of_the_pattern_matches_the_python_one():
+ """Guard the duplication: the JS branch must require a pipe too."""
+ if shutil.which("node") is None:
+ pytest.skip("node binary not on PATH")
+
+ cases = [text for text, _ in KEPT] + [text for text, _ in STRIPPED]
+ script = (
+ "const RE = %s;\n"
+ "const cases = JSON.parse(process.argv[1]);\n"
+ "console.log(JSON.stringify(cases.map(c => c.replace(RE, ' '))));"
+ % _js_bare_marker_regex_source()
+ )
+ result = subprocess.run(
+ ["node", "--input-type=module", "-e", script, json.dumps(cases)],
+ cwd=_REPO, capture_output=True, timeout=15, text=True,
+ )
+ assert result.returncode == 0, f"node failed:\n{result.stderr}"
+ got = json.loads(result.stdout.splitlines()[-1])
+
+ for (text, kept), out in zip(KEPT, got):
+ assert kept in out, f"JS regex dropped {kept!r} from {text!r}"
+ for (text, expected), out in zip(STRIPPED, got[len(KEPT):]):
+ assert out == expected, f"JS regex: {text!r} -> {out!r}, expected {expected!r}"
diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py
new file mode 100644
index 000000000..1da9d16c0
--- /dev/null
+++ b/tests/test_tts_service_enforce_cache_limit.py
@@ -0,0 +1,97 @@
+import os
+import time
+from pathlib import Path
+import pytest
+
+# Adjust the import path if your file is directly in ./services instead of ./services/tts
+from services.tts.tts_service import TTSService
+
+def test_cache_under_limit(tmp_path, monkeypatch):
+ """Test that writing a file under the size limit does not trigger eviction."""
+ # Set a tiny limit: 100 bytes
+ monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
+
+ # Initialize service with pytest's temporary directory
+ service = TTSService(cache_dir=str(tmp_path))
+
+ # Write a 40-byte file (under the 100-byte limit)
+ service._put_cache("test_key", b"x" * 40)
+
+ # Verify the file was written and nothing was deleted
+ files = list(tmp_path.glob("*.*"))
+ assert len(files) == 1
+ assert sum(f.stat().st_size for f in files) == 40
+
+def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch):
+ """Test that exceeding the limit evicts the oldest files down to 80% capacity."""
+ # Set limit to 100 bytes. 80% target capacity will be 80 bytes.
+ monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
+ service = TTSService(cache_dir=str(tmp_path))
+
+ # 1. Setup: Manually create two older files (40 bytes each)
+ file1 = tmp_path / "oldest.wav"
+ file2 = tmp_path / "middle.wav"
+
+ file1.write_bytes(b"a" * 40)
+ file2.write_bytes(b"b" * 40)
+
+ # Spoof timestamps so file1 is explicitly older than file2
+ now = time.time()
+ os.utime(file1, (now - 100, now - 100)) # 100 seconds ago
+ os.utime(file2, (now - 50, now - 50)) # 50 seconds ago
+
+ # 2. Action: Write a 3rd file using the service method (40 bytes)
+ # Total cache is now 120 bytes, which exceeds 100.
+ # It should delete oldest (file1) to drop to 80 bytes (which matches the 80% target).
+ service._put_cache("newest", b"c" * 40)
+
+ # 3. Assertions
+ # The newest file should exist (saved as .wav because it lacks MP3 magic bytes)
+ newest_file = tmp_path / "newest.wav"
+
+ assert not file1.exists(), "The oldest file should have been evicted."
+ assert file2.exists(), "The middle file should still exist."
+ assert newest_file.exists(), "The newest file should have been saved."
+
+ # Verify the final directory size is <= 80 bytes
+ total_size = sum(f.stat().st_size for f in tmp_path.glob("*.*"))
+ assert total_size <= 80
+
+def test_cache_limit_disabled(tmp_path, monkeypatch):
+ """Test that setting max bytes to 0 disables eviction."""
+ monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "0")
+ service = TTSService(cache_dir=str(tmp_path))
+
+ # Write 3 large files that would normally trigger eviction
+ service._put_cache("file1", b"x" * 1000)
+ service._put_cache("file2", b"x" * 1000)
+ service._put_cache("file3", b"x" * 1000)
+
+ # Ensure nothing was deleted
+ files = list(tmp_path.glob("*.*"))
+ assert len(files) == 3
+ assert sum(f.stat().st_size for f in files) == 3000
+
+def test_cache_eviction_handles_unlink_error_gracefully(tmp_path, monkeypatch):
+ """Test that if unlinking a file fails, _put_cache still succeeds without raising."""
+ service = TTSService(cache_dir=str(tmp_path))
+ service.max_cache_bytes = 50
+
+ # Create a file to evict
+ old_file = tmp_path / "old.wav"
+ old_file.write_bytes(b"x" * 40)
+
+ # Monkeypatch unlink on Path objects to simulate a PermissionError / file-lock failure
+ def mock_unlink(self_path):
+ raise OSError("Permission denied / file locked")
+
+ monkeypatch.setattr(Path, "unlink", mock_unlink)
+
+ # Writing a new file triggers eviction which encounters the mocked unlink error
+ try:
+ service._put_cache("new_key", b"y" * 40)
+ except Exception as e:
+ pytest.fail(f"_put_cache raised an exception during failed eviction: {e}")
+
+ # The new file should still be written successfully
+ assert (tmp_path / "new_key.wav").exists()
\ No newline at end of file
diff --git a/tests/test_vault_routes_shim.py b/tests/test_vault_routes_shim.py
new file mode 100644
index 000000000..9577395f7
--- /dev/null
+++ b/tests/test_vault_routes_shim.py
@@ -0,0 +1,11 @@
+"""Regression test for the vault route shim (slice 2k, #4082/#4071)."""
+
+import importlib
+
+import routes.vault_routes as _shim_vault # noqa: F401
+
+
+def test_legacy_and_canonical_vault_module_are_same_object():
+ legacy = importlib.import_module("routes.vault_routes")
+ canonical = importlib.import_module("routes.vault.vault_routes")
+ assert legacy is canonical
diff --git a/tests/test_vision_owner_scope.py b/tests/test_vision_owner_scope.py
index f0d3a184d..29de101a3 100644
--- a/tests/test_vision_owner_scope.py
+++ b/tests/test_vision_owner_scope.py
@@ -88,7 +88,7 @@ def test_request_vision_call_sites_pass_owner():
chat_source = (ROOT / "src" / "chat_handler.py").read_text()
processor_source = (ROOT / "src" / "document_processor.py").read_text()
upload_source = (ROOT / "routes" / "upload_routes.py").read_text()
- document_source = (ROOT / "routes" / "document_routes.py").read_text()
+ document_source = (ROOT / "routes" / "document" / "document_routes.py").read_text()
gallery_source = (ROOT / "routes" / "gallery" / "gallery_routes.py").read_text()
memory_source = (ROOT / "routes" / "memory" / "memory_routes.py").read_text()
diff --git a/tests/test_webhook_routes_shim.py b/tests/test_webhook_routes_shim.py
new file mode 100644
index 000000000..f6312e8e6
--- /dev/null
+++ b/tests/test_webhook_routes_shim.py
@@ -0,0 +1,11 @@
+"""Regression test for the webhook route shim (slice 2l, #4082/#4071)."""
+
+import importlib
+
+import routes.webhook_routes as _shim_webhook # noqa: F401
+
+
+def test_legacy_and_canonical_webhook_module_are_same_object():
+ legacy = importlib.import_module("routes.webhook_routes")
+ canonical = importlib.import_module("routes.webhook.webhook_routes")
+ assert legacy is canonical