Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:08 +00:00
parent 93107c5415
commit cf4e240ad1
246 changed files with 28636 additions and 5493 deletions
+1
View File
@@ -26,6 +26,7 @@ try:
import sqlalchemy # noqa: F401
import sqlalchemy.orm # noqa: F401
import core.database # noqa: F401
import src.database
except ImportError:
pass # not installed - the stubs below will handle it
+14
View File
@@ -56,6 +56,20 @@ def test_explicit_web_search_promotes_to_agent():
assert classify_tool_intent("use web search and find a recipe").category == "web"
def test_workspace_agent_requests_promote_to_shell_workspace():
prompts = [
"fix the bug in this repo",
"run the tests for this project",
"debug the server logs",
"run terminal-bench on this task",
"inspect the traceback and patch the code",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category == "workspace"
def test_explanatory_calendar_questions_stay_plain_chat():
assert not message_needs_tools("How do I add an entry to my calendar?")
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
+18
View File
@@ -28,6 +28,24 @@ def test_provider_selection_is_inert_and_add_button_starts_device_flow():
assert "_startProviderDeviceAuth(deviceAuthProvider" in add_block
def test_google_add_omits_auto_refresh_mode_for_backend_manual_default():
refresh_helper = _between(
_ADMIN,
"function _modelRefreshModeForApiEndpoint",
"function _normalizeBaseUrl",
)
add_block = _between(
_ADMIN,
"el('adm-epAddBtn').addEventListener('click'",
"async function _startProviderDeviceAuth",
)
assert "generativelanguage.googleapis.com" in refresh_helper
assert "return '';" in refresh_helper
assert "_modelRefreshModeForApiEndpoint(url, endpointKind)" in add_block
assert "if (refreshMode) fd.append('model_refresh_mode', refreshMode)" in add_block
def test_device_auth_selection_disables_and_dims_api_test_button():
form_block = _between(_ADMIN, "function _setApiFormForProvider()", "function _renderPickerMenu()")
+25
View File
@@ -0,0 +1,25 @@
"""Regression test for the admin_wipe route shim (slice 2h, #4082/#4071).
The backward-compat shim at ``routes/admin_wipe_routes.py`` uses
``sys.modules`` replacement so the legacy import path and the canonical
``routes.admin_wipe.*`` path resolve to the *same* module object. This is
required because ``test_admin_wipe_gallery.py`` does
``import routes.admin_wipe_routes`` followed by
``monkeypatch.setattr(routes.admin_wipe_routes, "SessionLocal", ...)`` and
``"require_admin"`` — for those patches to take effect at runtime, the legacy
module object and the canonical one must be identical.
"""
import importlib
import routes.admin_wipe_routes as _shim_admin_wipe # noqa: F401
def test_legacy_and_canonical_admin_wipe_module_are_same_object():
"""``import routes.admin_wipe_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.admin_wipe_routes")
canonical = importlib.import_module("routes.admin_wipe.admin_wipe_routes")
assert legacy is canonical, (
"routes.admin_wipe_routes shim must resolve to the canonical "
"routes.admin_wipe.admin_wipe_routes module object"
)
+21
View File
@@ -68,3 +68,24 @@ def test_no_rounds_exhausted_on_normal_finish(monkeypatch):
# A plain answer (no tool block) -> done-break on round 1 -> no event.
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=2)
assert not any(e.get("type") == "rounds_exhausted" for e in events), events
def test_emits_intent_nudge_exhausted_when_cap_is_exhausted(monkeypatch):
_patch_common(monkeypatch)
events = _run_loop(monkeypatch, "Let me check the logs", max_rounds=5)
guard = next((e for e in events if e.get("type") == "intent_nudge_exhausted"), None)
assert guard is not None, events
assert guard["reason"] == "intent_without_action_nudge_cap"
assert guard["nudges"] == 2
def test_emits_loop_breaker_triggered_when_loop_breaker_trips(monkeypatch):
_patch_common(monkeypatch)
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=6)
guard = next((e for e in events if e.get("type") == "loop_breaker_triggered"), None)
assert guard is not None, events
assert guard["reason"] == "loop_breaker_stall"
+268
View File
@@ -0,0 +1,268 @@
import os
import sys
import subprocess
from pathlib import Path
import pytest
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_created_with_0600(tmp_path):
"""app.db holds secrets — it must not be world-readable.
Note: under umask 077 a fresh sqlite file is born 0600 and this would pass
even without the chmod; dev/CI umask is 022, where the chmod is what makes
it pass. No umask machinery needed — just don't read a green here as proof
on a 077 box.
A subprocess (not in-process patching) is used deliberately: the engine
binds to DATABASE_URL at import time, so a fresh interpreter with its own
DATABASE_URL is the clean way to exercise init_db() against a real on-disk
file without rebinding the already-imported engine.
"""
db_file = tmp_path / "app.db"
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
repo_root = Path(__file__).resolve().parents[1]
# Importing core.database runs init_db() against the temp file-backed DB.
# cwd=repo_root so `import core` resolves (the `-c` sys.path[0] is the CWD).
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.exists()
mode = db_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
# Upgrade path: an already-deployed DB sitting at 0644 must be re-corrected
# on the next startup. The chmod is unconditional (not gated on create_all
# having created the file), so this is the common path for existing installs.
db_file.chmod(0o644)
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.stat().st_mode & 0o777 == 0o600, "existing 0644 DB not re-locked on startup"
def test_normalize_sqlite_url_preserves_sqlite_uri_filename():
"""URI filenames must reach SQLAlchemy unchanged for SQLite to parse."""
from core.database import _normalize_sqlite_url
url = "sqlite:///file:/tmp/app.db?mode=rwc&uri=true"
assert _normalize_sqlite_url(url) == url
def test_sqlite_db_path_handles_driver_and_query_forms():
"""The path fed to chmod must come from SQLAlchemy's parsed URL, not a naive
replace("sqlite:///"). A driver-qualified URL (sqlite+pysqlite://) or one
carrying query args (?cache=shared) would otherwise resolve to the wrong
path and leave the real file world-readable. Pure logic — runs everywhere.
"""
from sqlalchemy.engine import make_url
from core.database import _sqlite_db_path
# Plain forms (relative + absolute) resolve to the file path.
assert _sqlite_db_path(make_url("sqlite:///data/app.db")) == "data/app.db"
assert _sqlite_db_path(make_url("sqlite:////abs/app.db")) == "/abs/app.db"
# A driver qualifier must not defeat detection...
assert _sqlite_db_path(make_url("sqlite+pysqlite:///data/app.db")) == "data/app.db"
# ...and query args must be stripped from the path.
assert _sqlite_db_path(make_url("sqlite:///data/app.db?cache=shared")) == "data/app.db"
assert _sqlite_db_path(make_url("sqlite+pysqlite:////abs/app.db?mode=ro")) == "/abs/app.db"
# Nothing to lock for non-file-backed or non-sqlite databases.
assert _sqlite_db_path(make_url("sqlite:///:memory:")) is None
assert _sqlite_db_path(make_url("sqlite://")) is None
assert _sqlite_db_path(make_url("postgresql+psycopg2://u:p@h/db")) is None
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_sidecars_relocked(tmp_path):
"""Stale SQLite sidecars (-wal/-shm) left by an older 0o644 install hold
copies of DB pages, so startup must re-lock them too — not just app.db.
The default -journal is transient (SQLite deletes it after the create_all
commit), so it isn't asserted on here; -wal/-shm persist and are the real
exposure once WAL has ever been enabled.
"""
import sqlite3
db_file = tmp_path / "app.db"
sqlite3.connect(db_file).close() # a real, pre-existing DB ...
db_file.chmod(0o644)
sidecars = [tmp_path / f"app.db{sfx}" for sfx in ("-wal", "-shm")]
for s in sidecars:
s.write_bytes(b"")
s.chmod(0o644)
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
repo_root = Path(__file__).resolve().parents[1]
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.stat().st_mode & 0o777 == 0o600
for s in sidecars:
assert s.stat().st_mode & 0o777 == 0o600, f"{s.name} not re-locked on startup"
def test_sqlite_db_path_handles_file_uri_forms(tmp_path):
"""SQLite URI filenames must chmod the real filesystem path, not the
literal file: URI string. Memory URI databases should still be skipped."""
from sqlalchemy.engine import make_url
from core.database import _sqlite_db_path
db_file = tmp_path / "uri-app.db"
assert (
_sqlite_db_path(make_url(f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true"))
== str(db_file)
)
assert (
_sqlite_db_path(make_url(f"sqlite:///file:{db_file}?cache=shared&uri=true"))
== str(db_file)
)
localhost_db = tmp_path / "localhost-uri.db"
assert (
_sqlite_db_path(
make_url(
f"sqlite+pysqlite:///file://localhost{localhost_db}"
"?mode=rwc&uri=true"
)
)
== str(localhost_db)
)
non_uri_mode_db = tmp_path / "mode-query-file.db"
assert (
_sqlite_db_path(
make_url(
f"sqlite+pysqlite:///{non_uri_mode_db}?mode=memory"
)
)
== str(non_uri_mode_db)
)
assert (
_sqlite_db_path(make_url("sqlite+pysqlite:///file::memory:?cache=shared&uri=true"))
is None
)
assert (
_sqlite_db_path(make_url("sqlite+pysqlite:///file:memdb1?mode=memory&cache=shared&uri=true"))
is None
)
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_file_uri_created_with_0600(tmp_path):
"""Import-time DB initialization must lock SQLite file: URI databases too."""
db_file = tmp_path / "uri-app.db"
env = {
**os.environ,
"DATABASE_URL": f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true",
}
repo_root = Path(__file__).resolve().parents[1]
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.exists()
mode = db_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_localhost_file_uri_created_with_0600(tmp_path):
"""A file://localhost URI must chmod the local path SQLite opens."""
db_file = tmp_path / "localhost-uri.db"
env = {
**os.environ,
"DATABASE_URL": (
f"sqlite+pysqlite:///file://localhost{db_file}"
"?mode=rwc&uri=true"
),
}
repo_root = Path(__file__).resolve().parents[1]
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.exists()
mode = db_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_non_uri_mode_query_created_with_0600(tmp_path):
"""mode=memory without uri=true must not hide a real SQLite file."""
db_file = tmp_path / "mode-query-file.db"
env = {
**os.environ,
"DATABASE_URL": f"sqlite+pysqlite:///{db_file}?mode=memory",
}
repo_root = Path(__file__).resolve().parents[1]
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.exists()
mode = db_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
)
def test_app_db_plain_file_uri_created_with_0600(tmp_path):
"""The documented sqlite:///file: URI form must remain protected."""
db_file = tmp_path / "plain-uri-app.db"
env = {
**os.environ,
"DATABASE_URL": f"sqlite:///file:{db_file}?mode=rwc&uri=true",
}
repo_root = Path(__file__).resolve().parents[1]
subprocess.run(
[sys.executable, "-c", "import core.database"],
env=env,
cwd=repo_root,
check=True,
)
assert db_file.exists()
mode = db_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
+75
View File
@@ -0,0 +1,75 @@
import json
from src.attachment_refs import (
attachment_ref,
persistable_message_content,
search_index_text,
)
def test_persistable_message_content_replaces_inline_media_with_attachment_ref():
metadata = {
"attachments": [
{
"id": "abc123.png",
"name": "diagram.png",
"mime": "image/png",
"size": 42,
"checksum_sha256": "sha256-digest",
"created_at": "2026-07-09T12:00:00",
"vision": "A small architecture diagram.",
}
]
}
content = [
{"type": "text", "text": "Please inspect this."},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + ("A" * 5000)},
},
]
stored = persistable_message_content(content, metadata)
assert "base64" not in stored
assert "A" * 100 not in stored
assert "Please inspect this." in stored
assert "Attachment: diagram.png" in stored
assert "id=abc123.png" in stored
assert "sha256=sha256-digest" in stored
assert "A small architecture diagram." in stored
def test_search_index_text_strips_legacy_serialized_data_url_blocks():
legacy = json.dumps([
{"type": "text", "text": "Find this useful caption"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64," + ("B" * 4096)},
},
])
indexed = search_index_text(legacy)
assert indexed == "Find this useful caption\n[1 inline media payload omitted]"
def test_attachment_ref_normalizes_hash_aliases():
ref = attachment_ref({
"id": "file-id",
"original_name": "report.pdf",
"mime": "application/pdf",
"size": 99,
"hash": "abc",
"uploaded_at": "2026-07-09T12:00:00",
})
assert ref == {
"type": "attachment_ref",
"attachment_id": "file-id",
"name": "report.pdf",
"mime": "application/pdf",
"size": 99,
"checksum_sha256": "abc",
"created_at": "2026-07-09T12:00:00",
}
+40
View File
@@ -25,6 +25,46 @@ def _verify_args(path: Path):
return SimpleNamespace(path=str(path), pretty=False)
def test_backup_entry_skips_files_that_disappear():
backup = _load_backup_cli()
class Vanished:
name = "gone.tar.gz"
def is_file(self):
return True
def stat(self):
raise FileNotFoundError("gone")
def __str__(self):
return "backups/gone.tar.gz"
assert backup._backup_entry(Vanished()) is None
def test_backup_list_sorts_by_captured_mtime(monkeypatch):
backup = _load_backup_cli()
first = SimpleNamespace(name="older.tar.gz")
second = SimpleNamespace(name="newer.tar.gz")
monkeypatch.setattr(backup, "_BACKUP_DIR", SimpleNamespace(
is_dir=lambda: True,
iterdir=lambda: [first, second],
))
monkeypatch.setattr(backup, "_backup_entry", lambda p: {
"name": p.name,
"modified": "2026-10-25T01:45:00" if p is first else "2026-10-25T01:15:00",
"_mtime": 100 if p is first else 200,
})
seen = []
monkeypatch.setattr(backup, "emit", lambda payload, args: seen.append(payload))
backup.cmd_list(SimpleNamespace(pretty=False))
assert [entry["name"] for entry in seen[0]] == ["newer.tar.gz", "older.tar.gz"]
assert all("_mtime" not in entry for entry in seen[0])
def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch):
backup = _load_backup_cli()
repo = tmp_path / "repo"
+7 -9
View File
@@ -109,10 +109,9 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
def select(self, *_args, **_kwargs):
return "OK", []
def search(self, *_args, **_kwargs):
return "OK", [b"1 2 3"]
def fetch(self, _uid, _query):
def uid(self, command, *_args):
if command == "SEARCH":
return "OK", [b"1 2 3"]
return "OK", [(None, b"From: Writer <writer@example.com>\r\n\r\n")]
def logout(self):
@@ -171,11 +170,10 @@ async def test_learn_sender_signatures_writes_owner_scoped_cache(monkeypatch, tm
def select(self, *_args, **_kwargs):
return "OK", []
def search(self, *_args, **_kwargs):
return "OK", [b"1 2 3"]
def fetch(self, uid, query):
if "HEADER.FIELDS" in query:
def uid(self, command, uid=None, query=None):
if command == "SEARCH":
return "OK", [b"1 2 3"]
if query and "HEADER.FIELDS" in query:
return "OK", [(None, b"From: Writer <writer@example.com>\r\n\r\n")]
return "OK", [
(
+60
View File
@@ -36,6 +36,66 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
) == "@playwright/mcp@latest"
def test_browser_mcp_cache_requirement_is_opt_in(monkeypatch):
monkeypatch.delenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", raising=False)
builtin_mcp = _load_builtin_mcp(monkeypatch)
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is False
def test_browser_mcp_cache_requirement_can_be_enabled(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "1")
builtin_mcp = _load_builtin_mcp(monkeypatch)
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is True
def test_browser_mcp_args_use_configured_browser_executable(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "/usr/bin/chromium" in args
assert "--isolated" in args
assert "--no-sandbox" in args
def test_browser_mcp_args_can_use_persistent_profile_when_requested(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
monkeypatch.setenv("ODYSSEUS_BROWSER_ISOLATED", "0")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "--isolated" not in args
def test_browser_mcp_args_respect_explicit_user_data_dir(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args([
"-y", "@playwright/mcp@latest", "--headless", "--user-data-dir", "/tmp/profile",
])
assert "--user-data-dir" in args
assert "--isolated" not in args
def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
monkeypatch.setenv("ODYSSEUS_BROWSER_NO_SANDBOX", "0")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "--no-sandbox" not in args
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
builtin_mcp = _load_builtin_mcp(monkeypatch)
package_json = (
+22
View File
@@ -0,0 +1,22 @@
import os
from src.builtin_mcp import builtin_python_env
def test_builtin_python_env_preserves_existing_pythonpath(monkeypatch):
monkeypatch.setenv(
"PYTHONPATH",
os.pathsep.join(["/app/venv/lib/python3.13/site-packages", "/app", "/extra"]),
)
env = builtin_python_env("/app")
assert env == {
"PYTHONPATH": os.pathsep.join(["/app", "/app/venv/lib/python3.13/site-packages", "/extra"])
}
def test_builtin_python_env_uses_app_root_without_existing_pythonpath(monkeypatch):
monkeypatch.delenv("PYTHONPATH", raising=False)
assert builtin_python_env("/srv/odysseus") == {"PYTHONPATH": "/srv/odysseus"}
+44 -1
View File
@@ -80,7 +80,7 @@ async def test_consolidate_memory_empty_owner_treats_each_owner_separately(monke
message, ok = await action_consolidate_memory("")
assert ok is True
assert "removed 1" in message
assert "removed 1" in message.lower()
assert len(prompts) == 2
saved = {m["id"]: m for m in _read_memories(data_dir)}
assert set(saved) == {"alice-long", "alice-short", "bob-keep"}
@@ -114,3 +114,46 @@ async def test_consolidate_memory_specific_owner_does_not_absorb_ownerless_rows(
assert set(saved) == {"alice-1", "legacy", "bob-1"}
assert "owner" not in saved["legacy"]
assert saved["bob-1"]["owner"] == "bob"
@pytest.mark.asyncio
async def test_consolidate_memory_removes_near_duplicates_before_ai(monkeypatch, tmp_path):
from src import constants
from src import llm_core
from src import task_endpoint
action_consolidate_memory = _import_consolidate_action()
data_dir = _write_memories(
tmp_path,
[
{"id": "a", "owner": "alice", "text": "User prefers bullet points when explaining.", "category": "preference"},
{"id": "b", "owner": "alice", "text": "User prefers bulletpoints when explaining", "category": "preference", "pinned": True},
{"id": "c", "owner": "alice", "text": "User likes local models.", "category": "preference"},
],
)
monkeypatch.setattr(constants, "DATA_DIR", str(data_dir))
monkeypatch.setattr(
task_endpoint,
"resolve_task_candidates",
lambda *args, **kwargs: [("http://llm", "model", {})],
)
async def fake_llm_call_async(_candidates, **kwargs):
items = json.loads(kwargs["messages"][0]["content"].split("MEMORIES:\n", 1)[1])
return json.dumps({
"keep": [
{"id": item["id"], "text": item["text"], "category": item["category"]}
for item in items
],
"drop": [],
})
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_llm_call_async)
message, ok = await action_consolidate_memory("alice")
assert ok is True
assert "removed 1" in message.lower()
saved = {m["id"]: m for m in _read_memories(data_dir)}
assert set(saved) == {"b", "c"}
assert saved["b"]["pinned"] is True
+145
View File
@@ -0,0 +1,145 @@
"""Issue #4593 — the CalDAV DAVClient must be closed on every path.
`_sync_blocking` (src/caldav_sync.py) and `_writeback_blocking`
(src/caldav_writeback.py) each open their own DAVClient. The client holds an
HTTP session with pooled connections; if it is never closed those connections
leak for the lifetime of the process. These tests pin that the client is
closed on the discovery early-returns, the normal return, and the
write-back paths, using a fake client so no network or `caldav` install is
needed.
"""
import sys
import types
import pytest
from unittest.mock import MagicMock
def _stub_sync_deps(monkeypatch):
"""Make `_sync_blocking`'s lazy imports resolve without a real caldav/db."""
err_mod = types.ModuleType("caldav.lib.error")
class AuthorizationError(Exception):
pass
class NotFoundError(Exception):
pass
err_mod.AuthorizationError = AuthorizationError
err_mod.NotFoundError = NotFoundError
monkeypatch.setitem(sys.modules, "caldav", types.ModuleType("caldav"))
monkeypatch.setitem(sys.modules, "caldav.lib", types.ModuleType("caldav.lib"))
monkeypatch.setitem(sys.modules, "caldav.lib.error", err_mod)
db_mod = types.ModuleType("core.database")
db_mod.CalendarCal = MagicMock()
db_mod.CalendarEvent = MagicMock()
db_mod.CalendarDeletedEvent = MagicMock()
db_mod.SessionLocal = MagicMock()
if "core" not in sys.modules:
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
monkeypatch.setitem(sys.modules, "core.database", db_mod)
# Stub routes.calendar_routes so the lazy import of _ensure_positive_duration
# inside _sync_blocking doesn't drag in dateutil / FastAPI / SQLAlchemy.
routes_mod = types.ModuleType("routes")
cal_routes_mod = types.ModuleType("routes.calendar_routes")
cal_routes_mod._ensure_positive_duration = lambda start, end, all_day: end
if "routes" not in sys.modules:
monkeypatch.setitem(sys.modules, "routes", routes_mod)
monkeypatch.setitem(sys.modules, "routes.calendar_routes", cal_routes_mod)
return AuthorizationError
def test_sync_closes_client_on_discovery_auth_failure(monkeypatch):
import src.caldav_sync as sync
AuthorizationError = _stub_sync_deps(monkeypatch)
client = MagicMock()
client.principal.side_effect = AuthorizationError("bad credentials")
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
result = sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
client.close.assert_called_once()
assert any("Discovery failed" in e for e in result["errors"])
def test_sync_closes_client_when_url_fallback_fails(monkeypatch):
import src.caldav_sync as sync
_stub_sync_deps(monkeypatch)
client = MagicMock()
# principal() raises a generic error -> the URL-as-calendar fallback is
# tried; make that fail too so the function hits the early return.
client.principal.side_effect = RuntimeError("no principal endpoint")
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
monkeypatch.setattr(
sync, "_open_url_as_calendar",
MagicMock(side_effect=RuntimeError("not a calendar")),
)
result = sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
client.close.assert_called_once()
assert result["errors"]
def test_writeback_closes_client_when_no_calendars(monkeypatch):
import src.caldav_sync as sync
import src.caldav_writeback as wb
client = MagicMock()
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
monkeypatch.setattr(wb, "_discover_calendars", lambda c: [])
result = wb._writeback_blocking(
"caldav-1", {"uid": "evt-1"}, False, "https://dav.example.com/", "u", "p"
)
client.close.assert_called_once()
assert result["ok"] is False
def test_writeback_closes_client_on_success(monkeypatch):
import src.caldav_sync as sync
import src.caldav_writeback as wb
client = MagicMock()
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
monkeypatch.setattr(wb, "_discover_calendars", lambda c: [MagicMock()])
monkeypatch.setattr(wb, "push_event", lambda *a, **k: {"ok": True})
result = wb._writeback_blocking(
"caldav-1", {"uid": "evt-1"}, False, "https://dav.example.com/", "u", "p"
)
client.close.assert_called_once()
assert result["ok"] is True
def test_sync_closes_client_when_session_local_raises(monkeypatch):
import src.caldav_sync as sync
AuthorizationError = _stub_sync_deps(monkeypatch)
# Give principal() a working response so discovery passes
mock_principal = MagicMock()
mock_cal = MagicMock()
mock_cal.url = "https://dav.example.com/alice/home/"
mock_principal.calendars.return_value = [mock_cal]
client = MagicMock()
client.principal.return_value = mock_principal
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
# Make SessionLocal blow up before any DB work
import sys
sys.modules["core.database"].SessionLocal.side_effect = RuntimeError("DB unavailable")
with pytest.raises(RuntimeError, match="DB unavailable"):
sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
client.close.assert_called_once()
@@ -93,6 +93,10 @@ class _FakeClient:
def calendar(self, url=None):
return _FakeCalendar(url)
def close(self):
# Mirror the real DAVClient: sync now closes the client on every path.
self.closed = True
def _install_fake_caldav(monkeypatch):
fake = types.ModuleType("caldav")
+194
View File
@@ -0,0 +1,194 @@
"""Regression: CalDAV test_connection must trust the operator's CA bundle.
The pre-flight used httpx with trust_env=False, which ignored
SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Self-signed CalDAV servers that the
real sync accepts (via caldav lib -> requests -> honors bundle) were
rejected by the test with CERTIFICATE_VERIFY_FAILED.
These tests exercise the *route handler* directly (via ASGI TestClient)
and capture the verify= kwarg passed to httpx.AsyncClient, ensuring the
route code — not a test-side duplicate — builds the SSL context correctly.
"""
import os
import ssl
import sys
from unittest.mock import MagicMock, patch
import httpx
import pytest
# No module-level sys.modules stubbing here: conftest pre-imports the real
# sqlalchemy/core.database, and stubbing extras (e.g. caldav) at collection
# time leaks MagicMocks into later tests in the same process — it made
# test_caldav_redirect_hardening's real DAVClient a mock that never sent
# the PROPFIND. The route's lazy imports are patched per-request instead.
def _fake_response(status_code=207, headers=None):
resp = MagicMock()
resp.status_code = status_code
resp.headers = headers or {}
return resp
@pytest.fixture()
def client():
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.calendar_routes import setup_calendar_routes
with patch("routes.calendar_routes._require_user", return_value="test-owner"):
router = setup_calendar_routes()
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _make_fake_async_client(captured):
"""Return a fake httpx.AsyncClient class that captures constructor kwargs."""
class FakeAsyncClient:
def __init__(self, **kwargs):
captured.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def request(self, *a, **kw):
return _fake_response(207)
return FakeAsyncClient
def _post_test(client, captured, env=None):
"""POST /api/calendar/test with credentials in body so no DB lookup needed.
Patches httpx.AsyncClient at the real module level so the route's
``import httpx; httpx.AsyncClient(...)`` picks up the fake class.
Also stubs validate_caldav_url (lazy-imported from src.caldav_sync).
"""
fake_cls = _make_fake_async_client(captured)
# Stub the caldav_sync module so the lazy `from src.caldav_sync import validate_caldav_url`
# inside the route body resolves to a pass-through.
caldav_sync_stub = MagicMock()
caldav_sync_stub.validate_caldav_url = lambda u: u
ctx_managers = [
patch.object(httpx, "AsyncClient", fake_cls),
patch.dict(sys.modules, {"src.caldav_sync": caldav_sync_stub}),
patch("routes.calendar_routes._require_user", return_value="test-owner"),
]
if env is not None:
ctx_managers.append(patch.dict(os.environ, env))
# Enter all context managers
for cm in ctx_managers:
cm.__enter__()
try:
return client.post(
"/api/calendar/test",
json={"url": "https://cal.example.com", "username": "u", "password": "p"},
)
finally:
for cm in reversed(ctx_managers):
cm.__exit__(None, None, None)
# ---------------------------------------------------------------------------
# Route-level tests
# ---------------------------------------------------------------------------
def test_route_passes_ssl_context_with_correct_flags(client):
"""The route must pass an ssl.SSLContext to httpx.AsyncClient(verify=...)
with trust_env=False, follow_redirects=False, and VERIFY_X509_STRICT cleared."""
captured = {}
resp = _post_test(client, captured)
assert resp.status_code == 200
assert isinstance(captured.get("verify"), ssl.SSLContext), (
f"verify= should be an ssl.SSLContext, got {type(captured.get('verify'))}"
)
assert captured.get("trust_env") is False
assert captured.get("follow_redirects") is False
ctx = captured["verify"]
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT), (
"VERIFY_X509_STRICT must be cleared for self-signed CA compat"
)
def test_route_ssl_cert_file_takes_precedence(client, tmp_path):
"""SSL_CERT_FILE is the exact bundle loaded when both variables are set."""
bundle_a = tmp_path / "ssl-cert-file.pem"
bundle_b = tmp_path / "requests-ca-bundle.pem"
bundle_a.write_text("ssl-cert-file", encoding="utf-8")
bundle_b.write_text("requests-ca-bundle", encoding="utf-8")
loaded = []
class FakeSSLContext:
def __init__(self):
self.verify_flags = ssl.VERIFY_X509_STRICT
def load_verify_locations(self, cafile=None, capath=None, cadata=None):
loaded.append(
{
"cafile": cafile,
"capath": capath,
"cadata": cadata,
}
)
ssl_context = FakeSSLContext()
captured = {}
env = {
"SSL_CERT_FILE": str(bundle_a),
"REQUESTS_CA_BUNDLE": str(bundle_b),
}
with patch.object(
ssl,
"create_default_context",
return_value=ssl_context,
):
resp = _post_test(client, captured, env=env)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert loaded == [
{
"cafile": str(bundle_a),
"capath": None,
"cadata": None,
}
]
assert captured.get("verify") is ssl_context
assert captured.get("trust_env") is False
assert captured.get("follow_redirects") is False
assert not (
ssl_context.verify_flags & ssl.VERIFY_X509_STRICT
)
def test_route_missing_bundle_does_not_crash(client):
"""A nonexistent CA bundle path must not crash -- fall back to system CAs."""
captured = {}
resp = _post_test(client, captured, env={"SSL_CERT_FILE": "/nonexistent/ca-bundle.pem"})
assert resp.status_code == 200
ctx = captured["verify"]
assert isinstance(ctx, ssl.SSLContext)
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT)
def test_route_empty_env_vars_use_system_defaults(client):
"""Empty SSL_CERT_FILE and REQUESTS_CA_BUNDLE should not crash."""
captured = {}
resp = _post_test(client, captured, env={"SSL_CERT_FILE": "", "REQUESTS_CA_BUNDLE": ""})
assert resp.status_code == 200
ctx = captured["verify"]
assert isinstance(ctx, ssl.SSLContext)
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT)
+24 -4
View File
@@ -233,6 +233,10 @@ def test_build_uploaded_file_manifest_filters_and_nulls_unreadable_paths(monkeyp
)
assert [item["id"] for item in manifest] == ["good", "outside", "missing"]
assert manifest[0]["type"] == "attachment_ref"
assert manifest[0]["attachment_id"] == "good"
assert manifest[0]["uri"] == "odysseus://attachment/good"
assert manifest[0]["read_policy"] == "owner_checked_upload"
assert os.path.realpath(manifest[0]["path"]) == os.path.realpath(good)
assert manifest[1]["path"] is None
assert manifest[2]["path"] is None
@@ -338,10 +342,10 @@ def test_clean_thinking_for_save_extracts_thought_tag():
assert metadata["thinking"] == "internal reasoning"
def test_save_assistant_response_preserves_actual_and_requested_model():
def test_save_assistant_response_incognito_does_not_mutate_session_history():
sess = _FakeSession("selected-model")
save_assistant_response(
saved_id = save_assistant_response(
sess,
session_manager=None,
session_id="s1",
@@ -350,8 +354,24 @@ def test_save_assistant_response_preserves_actual_and_requested_model():
incognito=True,
)
assert sess.history[-1].metadata["requested_model"] == "selected-model"
assert sess.history[-1].metadata["model"] == "actual-model"
assert saved_id is None
assert sess.history == []
def test_add_user_message_incognito_does_not_mutate_session_history():
sess = _FakeSession("selected-model")
chat_handler = SimpleNamespace(update_session_name_if_needed=lambda *_args, **_kwargs: None)
preprocessed = PreprocessedMessage(
enhanced_message="secret",
user_content="secret",
text_for_context="secret",
youtube_transcripts=[],
attachment_meta=[],
)
chat_helpers.add_user_message(sess, chat_handler, preprocessed, incognito=True)
assert sess.history == []
class _SpinMsg:
+9
View File
@@ -60,6 +60,15 @@ def test_image_model_prefix_routes_to_image_generation_without_endpoint_lookup(m
assert chat_routes._is_image_generation_session(_session(model="dall-e-3"))
def test_namespaced_gpt_image_model_routes_to_image_generation_without_endpoint_lookup(monkeypatch):
def fail_if_called():
raise AssertionError("provider-prefixed image models should not need a DB lookup")
monkeypatch.setattr(chat_routes, "SessionLocal", fail_if_called)
assert chat_routes._is_image_generation_session(_session(model="openai/gpt-5-image"))
def test_image_endpoint_does_not_catch_text_model_on_different_path(monkeypatch):
db = _FakeDb([
_endpoint("http://localhost:11434/v1/images", models=["sdxl-local"]),
+152
View File
@@ -0,0 +1,152 @@
from types import SimpleNamespace
from src.chat_processor import ChatProcessor
class _Memory:
def __init__(self, rows):
self.rows = rows
self.incremented = []
def load(self, owner=None):
return list(self.rows)
def increment_uses(self, ids):
self.incremented.extend(ids)
class _Docs:
rag_manager = None
def _context_text(preface):
return "\n".join(m.get("content", "") for m in preface)
def _processor(rows):
return ChatProcessor(memory_manager=_Memory(rows), personal_docs_manager=_Docs())
def test_pinned_memory_does_not_inject_every_unrelated_fact():
rows = [
{
"id": "identity",
"text": "User's name is Felix.",
"category": "identity",
"pinned": True,
"timestamp": 3,
},
{
"id": "party",
"text": "User is planning a birthday party with sack races.",
"category": "fact",
"pinned": True,
"timestamp": 2,
},
{
"id": "coffee",
"text": "User likes dark roast coffee.",
"category": "preference",
"pinned": True,
"timestamp": 1,
},
]
preface, _, _ = _processor(rows).build_context_preface(
message="Explain how Python decorators work",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
text = _context_text(preface)
assert "User's name is Felix." in text
assert "birthday party with sack races" not in text
assert "dark roast coffee" not in text
def test_relevant_pinned_memory_is_still_injected():
rows = [
{
"id": "coffee",
"text": "User likes dark roast coffee.",
"category": "preference",
"pinned": True,
"timestamp": 1,
},
{
"id": "party",
"text": "User is planning a birthday party with sack races.",
"category": "fact",
"pinned": True,
"timestamp": 2,
},
]
preface, _, _ = _processor(rows).build_context_preface(
message="likes coffee roast",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
text = _context_text(preface)
assert "User likes dark roast coffee." in text
assert "birthday party with sack races" not in text
def test_pinned_memory_injection_is_capped_at_five():
rows = [
{
"id": f"identity-{idx}",
"text": f"User identity fact {idx} email marker.",
"category": "identity",
"pinned": True,
"timestamp": idx,
}
for idx in range(10)
]
processor = _processor(rows)
processor.build_context_preface(
message="Who is the user?",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
assert len(processor._last_used_memories) == 5
def test_total_memory_injection_is_capped_at_five_across_pinned_and_recalled():
rows = [
{
"id": f"identity-{idx}",
"text": f"User identity fact {idx} email marker.",
"category": "identity",
"pinned": True,
"timestamp": idx,
}
for idx in range(4)
]
rows.extend([
{
"id": f"coffee-{idx}",
"text": f"User likes coffee roast {idx}.",
"category": "preference",
"pinned": False,
"timestamp": idx,
}
for idx in range(6)
])
processor = _processor(rows)
processor.build_context_preface(
message="likes coffee roast",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
assert len(processor._last_used_memories) <= 5
assert sum(1 for m in processor._last_used_memories if m["type"] == "pinned") == 4
+25
View File
@@ -79,6 +79,23 @@ def test_allow_web_search_reads_from_body_as_fallback():
)
def test_browser_form_followups_include_approval_and_send_phrases():
"""Short approval replies after a form/browser turn must keep browser tools available."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "approved" in source
assert "proceed" in source
assert "send(?:\\s+it)?" in source
assert "submit(?:\\s+it)?" in source
def test_agent_loop_expands_browser_mcp_tools_from_connected_server():
"""Browser intent must not depend on stale hardcoded Playwright tool names."""
source = (Path(__file__).resolve().parent.parent / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "def _expand_browser_mcp_tools" in source
assert "server_id\") == \"builtin_browser\"" in source
assert "_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr)" in source
def test_disabled_tools_respects_missing_vs_explicit_toggles():
"""Bash still defers to privileges, but web is an explicit per-turn opt-in.
"""
@@ -102,6 +119,14 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
)
def test_workspace_auto_escalation_keeps_shell_tools():
"""Workspace/shell auto-routing must not use the light typed-tool clamp."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert '_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}' in source
assert "allow_bash = \"true\"" in source
assert "if auto_escalated and not _workspace_agent_intent:" in source
# ── Functional tests of the disabled-tools logic ───────────────
+25
View File
@@ -0,0 +1,25 @@
"""Regression test for the cleanup route shim (slice 2g, #4082/#4071).
The backward-compat shim at ``routes/cleanup_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.cleanup.*``
path resolve to the *same* module object. This is required because
``test_cleanup_owner_scope.py`` uses string-targeted
``monkeypatch.setattr("routes.cleanup_routes.get_cleanup_preview", ...)`` and
``monkeypatch.delitem(sys.modules, "routes.cleanup_routes")`` + re-import —
for those patches to take effect at runtime, the legacy module object and
the canonical one must be identical.
"""
import importlib
import routes.cleanup_routes as _shim_cleanup # noqa: F401
def test_legacy_and_canonical_cleanup_module_are_same_object():
"""``import routes.cleanup_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.cleanup_routes")
canonical = importlib.import_module("routes.cleanup.cleanup_routes")
assert legacy is canonical, (
"routes.cleanup_routes shim must resolve to the canonical "
"routes.cleanup.cleanup_routes module object"
)
+3 -2
View File
@@ -199,7 +199,8 @@ async def test_documents_pagination_out_of_range_offset_returns_empty_page():
assert result["next_offset"] is None
def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch):
@pytest.mark.parametrize("host_field", ["host", "remote_host"])
def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch, host_field):
calls = []
async def fail_if_shell_runs(*args, **kwargs):
@@ -212,7 +213,7 @@ def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch):
body = {
"tmux_session": "serve_abc123",
"model": "org/model",
"host": "-oProxyCommand=sh",
host_field: "-oProxyCommand=sh",
}
with pytest.raises(HTTPException) as exc:
+25
View File
@@ -0,0 +1,25 @@
"""Regression test for the compare route shim (slice 2i, #4082/#4071).
The backward-compat shim at ``routes/compare_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.compare.*``
path resolve to the *same* module object. This is required because
``test_endpoint_owner_scope_followup.py`` uses ``import routes.compare_routes
as cr`` followed by ``monkeypatch.setattr(cr, "SessionLocal", ...)`` /
``"_owned_endpoint_by_url"`` / ``"_owned_endpoint_by_id"`` — for those patches
to take effect at runtime, the legacy module object and the canonical one
must be identical.
"""
import importlib
import routes.compare_routes as _shim_compare # noqa: F401
def test_legacy_and_canonical_compare_module_are_same_object():
"""``import routes.compare_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.compare_routes")
canonical = importlib.import_module("routes.compare.compare_routes")
assert legacy is canonical, (
"routes.compare_routes shim must resolve to the canonical "
"routes.compare.compare_routes module object"
)
+49 -18
View File
@@ -4,10 +4,12 @@ Driven through `node --input-type=module` so we exercise the real JS without a
full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips
when `node` is not installed rather than failing.
Locks in: empty composer recalls last user message; non-empty composer is
untouched; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp
are ignored; IME composition does not trigger recall; last message is read from
#chat-history (dataset.raw), not session sidebar metadata.
Locks in: empty composer recalls user messages from the active conversation,
repeated ArrowUp walks older prompts in that same chat; non-empty composer is
untouched unless it contains the recalled prompt; multiline caret navigation is
not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp are ignored; IME composition does not
trigger recall; messages are read from #chat-history (dataset.raw), not session
sidebar metadata.
"""
import json
import shutil
@@ -36,6 +38,8 @@ function makeComposer(initial = '') {
},
dispatchKey(opts = {}) {
let prevented = false;
let stopped = false;
let immediateStopped = false;
const e = {
key: opts.key ?? 'ArrowUp',
shiftKey: !!opts.shiftKey,
@@ -44,9 +48,11 @@ function makeComposer(initial = '') {
metaKey: !!opts.metaKey,
isComposing: !!opts.isComposing,
preventDefault() { prevented = true; },
stopPropagation() { stopped = true; },
stopImmediatePropagation() { immediateStopped = true; },
};
for (const fn of listeners) fn(e);
return prevented;
return { prevented, stopped, immediateStopped };
},
};
return composer;
@@ -58,17 +64,20 @@ function runCase(body) {
composer.selectionStart = body.caret;
composer.selectionEnd = body.caretEnd ?? body.caret;
}
const last = body.last ?? 'previous message';
const last = body.history ?? body.last ?? 'previous message';
let resized = false;
wireArrowUpRecall(composer, () => last, {
autoResize: () => { resized = true; },
});
const prevented = composer.dispatchKey(body.event ?? {});
const events = body.events ?? [body.event ?? {}];
const handled = events.map(ev => composer.dispatchKey(ev));
return {
value: composer.value,
selectionStart: composer.selectionStart,
selectionEnd: composer.selectionEnd,
prevented,
prevented: handled.map(v => v.prevented),
stopped: handled.map(v => v.stopped),
immediateStopped: handled.map(v => v.immediateStopped),
resized,
};
}
@@ -100,7 +109,24 @@ def test_empty_composer_recalls_last_user_message():
assert out["value"] == "hello again"
assert out["selectionStart"] == len("hello again")
assert out["selectionEnd"] == len("hello again")
assert out["prevented"] is True
assert out["prevented"] == [True]
assert out["stopped"] == [True]
assert out["immediateStopped"] == [True]
assert out["resized"] is True
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_repeated_arrow_up_cycles_current_chat_prompts_newest_first():
out = _run([{
"initial": "",
"history": ["third prompt", "second prompt", "first prompt"],
"events": [{}, {}, {}, {}],
}])[0]
assert out["value"] == "first prompt"
assert out["selectionStart"] == len("first prompt")
assert out["prevented"] == [True, True, True, True]
assert out["stopped"] == [True, True, True, True]
assert out["immediateStopped"] == [True, True, True, True]
assert out["resized"] is True
@@ -108,7 +134,7 @@ def test_empty_composer_recalls_last_user_message():
def test_non_empty_composer_does_not_recall():
out = _run([{"initial": "draft in progress", "last": "ignored"}])[0]
assert out["value"] == "draft in progress"
assert out["prevented"] is False
assert out["prevented"] == [False]
assert out["resized"] is False
@@ -116,7 +142,7 @@ def test_non_empty_composer_does_not_recall():
def test_whitespace_only_composer_is_not_empty():
out = _run([{"initial": " ", "last": "ignored"}])[0]
assert out["value"] == " "
assert out["prevented"] is False
assert out["prevented"] == [False]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@@ -126,7 +152,7 @@ def test_multiline_caret_navigation_preserved():
out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0]
assert out["value"] == text
assert out["selectionStart"] == len(text)
assert out["prevented"] is False
assert out["prevented"] == [False]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@@ -139,21 +165,21 @@ def test_modified_arrow_up_ignored():
]
for out in _run(cases):
assert out["value"] == ""
assert out["prevented"] is False
assert out["prevented"] == [False]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_ime_composition_does_not_trigger_recall():
out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0]
assert out["value"] == ""
assert out["prevented"] is False
assert out["prevented"] == [False]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_no_recall_when_last_message_missing():
out = _run([{"initial": "", "last": ""}])[0]
assert out["value"] == ""
assert out["prevented"] is False
assert out["prevented"] == [False]
assert out["resized"] is False
@@ -182,7 +208,10 @@ def test_wire_is_idempotent():
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_get_last_user_message_from_chat_history():
js = f"""
import {{ getLastUserMessageFromChatHistory }} from '{_HELPER_URL}';
import {{
getLastUserMessageFromChatHistory,
getUserMessagesFromChatHistory,
}} from '{_HELPER_URL}';
const chatBox = {{
id: 'chat-history',
@@ -202,6 +231,7 @@ def test_get_last_user_message_from_chat_history():
console.log(JSON.stringify({{
fromChat: getLastUserMessageFromChatHistory(doc),
fromBox: getLastUserMessageFromChatHistory(chatBox),
allFromChat: getUserMessagesFromChatHistory(doc),
empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}),
noUsers: getLastUserMessageFromChatHistory({{
getElementById: () => ({{ querySelectorAll: () => [] }}),
@@ -221,6 +251,7 @@ def test_get_last_user_message_from_chat_history():
assert json.loads(proc.stdout.strip()) == {
"fromChat": "last raw",
"fromBox": "last raw",
"allFromChat": ["last raw", "first"],
"empty": "",
"noUsers": "",
}
@@ -231,7 +262,7 @@ def test_integration_recalls_from_chat_history_dom():
js = f"""
import {{
wireArrowUpRecall,
getLastUserMessageFromChatHistory,
getUserMessagesFromChatHistory,
}} from '{_HELPER_URL}';
const chatBox = {{
@@ -251,7 +282,7 @@ def test_integration_recalls_from_chat_history_dom():
_arrowUpRecallWired: false,
addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }},
}};
wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(doc));
wireArrowUpRecall(composer, () => getUserMessagesFromChatHistory(doc));
let prevented = false;
listeners[0]({{
key: 'ArrowUp',
+12
View File
@@ -89,6 +89,18 @@ def test_request_flags_vision():
assert vision is True
def test_request_flags_non_dict_last_message_does_not_crash():
# A client can send a bare-string (non-dict) last element; before the
# isinstance guard this raised AttributeError on last.get("role").
assert copilot.request_flags(["hi"]) == (False, False)
assert copilot.request_flags([{"role": "user"}, "trailing"]) == (False, False)
def test_request_flags_empty_and_none():
assert copilot.request_flags([]) == (False, False)
assert copilot.request_flags(None) == (False, False)
def test_apply_request_headers_mutates():
h = {"X-GitHub-Api-Version": "v"}
copilot.apply_request_headers(h, [{"role": "tool", "content": "x"}])
@@ -0,0 +1,74 @@
"""Regression: extensionless image/audio uploads must get a valid MIME subtype.
The data-URL subtype was derived only from the stored file's extension
(`image_format = ext[1:]`). A pasted screenshot or any file whose stored id
carries no extension yields `ext == ""`, so the emitted URL was
`data:image/;base64,...` — an empty MIME subtype (invalid per RFC 2046) that
vision/audio endpoints reject, silently dropping the attachment. When the
extension is missing, fall back to the resolved MIME subtype. Extensions that
are present are unchanged.
"""
class _Handler:
def __init__(self, uploads, image=False, audio=False):
self.uploads = uploads
self._image = image
self._audio = audio
def resolve_upload(self, fid, owner=None):
return self.uploads.get(fid)
def _inside_upload_dir(self, path):
return True
def is_image_file(self, name, mime):
return self._image and (mime or "").startswith("image/")
def is_audio_file(self, name, mime):
return self._audio and (mime or "").startswith("audio/")
def is_document_file(self, name, mime):
return False
def _blocks(content, block_type):
return [b for b in content if isinstance(b, dict) and b.get("type") == block_type]
def test_extensionless_image_uses_mime_subtype(tmp_path):
import src.document_processor as dp
p = tmp_path / ("a" * 32) # bare id, no extension
p.write_bytes(b"\x89PNG\r\n\x1a\nfake")
uploads = {"img": {"path": str(p), "name": "screenshot", "mime": "image/png"}}
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
imgs = _blocks(content, "image_url")
assert imgs, content
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_extensionless_audio_uses_mime_subtype(tmp_path):
import src.document_processor as dp
p = tmp_path / ("b" * 32)
p.write_bytes(b"fakeaudio")
uploads = {"aud": {"path": str(p), "name": "recording", "mime": "audio/mpeg"}}
content = dp.build_user_content("listen", ["aud"], str(tmp_path), _Handler(uploads, audio=True), owner="t")
auds = _blocks(content, "audio")
assert auds, content
assert auds[0]["audio"]["url"].startswith("data:audio/mpeg;base64,")
def test_extension_present_is_unchanged(tmp_path):
import src.document_processor as dp
p = tmp_path / "pic.png"
p.write_bytes(b"\x89PNG\r\n\x1a\n")
uploads = {"img": {"path": str(p), "name": "pic.png", "mime": "image/png"}}
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
imgs = _blocks(content, "image_url")
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
+40
View File
@@ -12,6 +12,16 @@ def _bulk_action_source() -> str:
return text[start:end]
def _function_source(name: str) -> str:
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
start = text.index(f"function {name}")
next_function = text.find("\nfunction ", start + 1)
next_async = text.find("\nasync function ", start + 1)
candidates = [idx for idx in (next_function, next_async) if idx != -1]
end = min(candidates) if candidates else len(text)
return text[start:end]
def test_email_bulk_read_unread_calls_provider_write_routes():
"""Bulk read/unread must persist to IMAP/provider, not only mutate UI state.
@@ -34,3 +44,33 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
assert "data?.success === false" in src
assert "throw new Error(data?.error" in src
assert "_libCacheWriteBack()" in src
def test_email_context_changes_clear_bulk_selection_state():
"""IMAP UIDs are folder/account scoped, so stale bulk selections must die.
Folder, account, filter, quick-filter, attachment, and search basis changes
must exit select mode before the next list/search view can run bulk actions.
"""
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
reset_src = _function_source("_resetBulkSelectionForContextChange")
fresh_src = _function_source("_resetEmailListForFreshLoad")
add_pill_src = _function_source("_addSearchPill")
remove_pill_src = _function_source("_removeSearchPillAt")
search_src = text[text.index("async function _doSearch()"):text.index("// Custom dropdown", text.index("async function _doSearch()"))]
assert "state._selectedUids.clear()" in reset_src
assert "state._selectMode = false" in reset_src
assert "_updateBulkBar()" in reset_src
assert "_resetBulkSelectionForContextChange()" in fresh_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in add_pill_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in remove_pill_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in search_src
assert "state._libFolder = e.target.value;" in text
assert "state._libFilter = e.target.value;" in text
assert "state._libHasAttachments = !state._libHasAttachments;" in text
assert "state._libAccountId = btn.dataset.accId || null;" in text
assert text.count("_loadEmailsFresh();") >= 5
assert "state._libSearchDraft = input.value;" in text
+219 -1
View File
@@ -372,7 +372,15 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
from core.database import EmailAccount
db, Factory = _make_db()
_make_account(db, account_id="acct-v", owner="alice", imap_host="", smtp_host="")
_make_account(
db,
account_id="acct-v",
owner="alice",
imap_host="",
smtp_host="",
imap_user="alice@nyu.edu",
smtp_user="ALICE@NYU.EDU",
)
_make_account(db, account_id="acct-other", owner="alice") # must stay untouched
db.close()
@@ -407,6 +415,166 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
assert other.oauth_access_token is None, "tokens must only touch the intended account"
@pytest.mark.asyncio
async def test_callback_rejects_token_for_a_different_mailbox_identity():
"""Reconnecting with another Google identity must not replace the token
while retaining the original IMAP/SMTP login names."""
from routes.email_helpers import make_oauth_state
from src.secret_storage import encrypt as _enc, decrypt as _dec
from core.database import EmailAccount
db, Factory = _make_db()
_make_account(
db,
account_id="acct-reconnect",
owner="alice",
imap_user="alice@example.edu",
smtp_user="alice@example.edu",
oauth_provider="google",
oauth_access_token=_enc("ya29.existing_access"),
oauth_refresh_token=_enc("1//existing_refresh"),
)
db.close()
token_resp = mock.MagicMock()
token_resp.raise_for_status = mock.MagicMock()
token_resp.json.return_value = {
"access_token": "ya29.other_access",
"refresh_token": "1//other_refresh",
"expires_in": 3600,
}
userinfo_resp = mock.MagicMock()
userinfo_resp.is_success = True
userinfo_resp.json.return_value = {
"email": "other@example.edu",
"name": "Other User",
}
state = make_oauth_state("acct-reconnect", "alice")
with mock.patch("httpx.post", return_value=token_resp), \
mock.patch("httpx.get", return_value=userinfo_resp), \
mock.patch("core.database.SessionLocal", Factory):
resp = await _callback_endpoint()(
code="4/code",
state=state,
error=None,
request=_FakeRequest(),
)
assert "email_oauth_error=identity_verification_failed" in _location(resp)
verify_db = Factory()
row = verify_db.query(EmailAccount).filter(
EmailAccount.id == "acct-reconnect"
).first()
verify_db.close()
assert _dec(row.oauth_access_token) == "ya29.existing_access"
assert _dec(row.oauth_refresh_token) == "1//existing_refresh"
@pytest.mark.asyncio
async def test_callback_rejects_reconnect_without_a_fresh_refresh_token():
"""A same-identity access token cannot be paired with an unproven refresh
token retained from a previously mixed row."""
from routes.email_helpers import make_oauth_state
from src.secret_storage import encrypt as _enc, decrypt as _dec
from core.database import EmailAccount
db, Factory = _make_db()
_make_account(
db,
account_id="acct-refresh-proof",
owner="alice",
imap_user="alice@example.edu",
smtp_user="alice@example.edu",
oauth_provider="google",
oauth_access_token=_enc("ya29.existing_access"),
oauth_refresh_token=_enc("1//refresh_for_other_identity"),
)
db.close()
token_resp = mock.MagicMock()
token_resp.raise_for_status = mock.MagicMock()
token_resp.json.return_value = {
"access_token": "ya29.same_identity_access",
"expires_in": 3600,
}
state = make_oauth_state("acct-refresh-proof", "alice")
with mock.patch("httpx.post", return_value=token_resp), \
mock.patch("httpx.get") as userinfo_get, \
mock.patch("core.database.SessionLocal", Factory):
resp = await _callback_endpoint()(
code="4/code",
state=state,
error=None,
request=_FakeRequest(),
)
assert "email_oauth_error=token_exchange_failed" in _location(resp)
userinfo_get.assert_not_called()
verify_db = Factory()
row = verify_db.query(EmailAccount).filter(
EmailAccount.id == "acct-refresh-proof"
).first()
verify_db.close()
assert _dec(row.oauth_access_token) == "ya29.existing_access"
assert _dec(row.oauth_refresh_token) == "1//refresh_for_other_identity"
@pytest.mark.asyncio
@pytest.mark.parametrize("userinfo_result", [None, {}, {"email": None}])
async def test_callback_requires_verified_mailbox_identity(userinfo_result):
"""A failed or incomplete userinfo lookup must not persist fresh tokens."""
from routes.email_helpers import make_oauth_state
from core.database import EmailAccount
db, Factory = _make_db()
_make_account(
db,
account_id="acct-no-identity",
owner="alice",
imap_user="alice@example.edu",
smtp_user="alice@example.edu",
)
db.close()
token_resp = mock.MagicMock()
token_resp.raise_for_status = mock.MagicMock()
token_resp.json.return_value = {
"access_token": "ya29.unverified_access",
"refresh_token": "1//unverified_refresh",
"expires_in": 3600,
}
if userinfo_result is None:
userinfo_call = mock.Mock(side_effect=RuntimeError("userinfo unavailable"))
else:
userinfo_resp = mock.MagicMock()
userinfo_resp.is_success = True
userinfo_resp.json.return_value = userinfo_result
userinfo_call = mock.Mock(return_value=userinfo_resp)
state = make_oauth_state("acct-no-identity", "alice")
with mock.patch("httpx.post", return_value=token_resp), \
mock.patch("httpx.get", userinfo_call), \
mock.patch("core.database.SessionLocal", Factory):
resp = await _callback_endpoint()(
code="4/code",
state=state,
error=None,
request=_FakeRequest(),
)
assert "email_oauth_error=identity_verification_failed" in _location(resp)
verify_db = Factory()
row = verify_db.query(EmailAccount).filter(
EmailAccount.id == "acct-no-identity"
).first()
verify_db.close()
assert row.oauth_provider is None
assert row.oauth_access_token is None
assert row.oauth_refresh_token is None
# ── Token refresh scenarios ───────────────────────────────────────
def test_get_valid_google_token_uses_cached_when_fresh():
@@ -578,3 +746,53 @@ async def test_account_list_response_does_not_expose_token_values():
assert acct["oauth_provider"] == "google" # status is exposed
assert "oauth_access_token" not in acct # token value is not
assert "oauth_refresh_token" not in acct
@pytest.mark.asyncio
async def test_config_response_does_not_expose_oauth_storage_fields():
"""The client-facing config route must not serialize the encrypted token
fields returned by the internal transport helper."""
from routes.email_routes import setup_email_routes
from src.secret_storage import encrypt as _enc
encrypted_access = _enc("ya29.internal_access_token")
encrypted_refresh = _enc("1//internal_refresh_token")
internal_cfg = {
"account_id": "acct-config",
"account_name": "Google Workspace",
"smtp_host": "smtp.gmail.com",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_user": "alice@example.edu",
"smtp_password": "",
"imap_host": "imap.gmail.com",
"imap_port": 993,
"imap_user": "alice@example.edu",
"imap_password": "",
"imap_starttls": False,
"from_address": "alice@example.edu",
"oauth_provider": "google",
"oauth_access_token": encrypted_access,
"oauth_refresh_token": encrypted_refresh,
"oauth_token_expiry": "4102444800",
"display_name": "Alice",
}
router = setup_email_routes()
get_config = None
for route in router.routes:
if route.path == "/api/email/config" and "GET" in getattr(route, "methods", set()):
get_config = route.endpoint
break
assert get_config is not None, "email config route not found"
with mock.patch("routes.email_routes._get_email_config", return_value=internal_cfg.copy()), \
mock.patch("routes.email_routes._load_settings", return_value={}):
result = await get_config(owner="alice")
assert result["oauth_provider"] == "google"
assert "oauth_access_token" not in result
assert "oauth_refresh_token" not in result
assert "oauth_token_expiry" not in result
assert encrypted_access not in json.dumps(result)
assert encrypted_refresh not in json.dumps(result)
+68
View File
@@ -0,0 +1,68 @@
"""Regression coverage for Google OAuth configuration in Docker Compose."""
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parent.parent
COMPOSE_PATHS = tuple(
ROOT / name
for name in (
"docker-compose.yml",
"docker-compose.gpu-nvidia.yml",
"docker-compose.gpu-amd.yml",
)
)
ENV_EXAMPLE_PATH = ROOT / ".env.example"
def _env_example():
if not ENV_EXAMPLE_PATH.exists():
pytest.skip("this checkout does not include the optional .env.example file")
return ENV_EXAMPLE_PATH.read_text(encoding="utf-8")
def _odysseus_environment(path):
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
return set(compose["services"]["odysseus"]["environment"])
@pytest.mark.parametrize(
"key",
(
"GOOGLE_OAUTH_CLIENT_ID",
"GOOGLE_OAUTH_CLIENT_SECRET",
"GOOGLE_OAUTH_REDIRECT_URI",
),
)
def test_google_oauth_setting_is_forwarded(key):
expected = f"{key}=${{{key}:-}}"
for path in COMPOSE_PATHS:
assert expected in _odysseus_environment(path), path.name
@pytest.mark.parametrize(
"key",
(
"GOOGLE_OAUTH_CLIENT_ID",
"GOOGLE_OAUTH_CLIENT_SECRET",
"GOOGLE_OAUTH_REDIRECT_URI",
),
)
def test_google_oauth_setting_is_documented(key):
assert f"# {key}=" in _env_example()
def test_google_oauth_example_uses_a_neutral_secret_placeholder():
env_example = _env_example()
assert "GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret" in env_example
assert "GOCSPX-" not in env_example
def test_redirect_documentation_covers_https_and_reverse_proxies():
oauth_section = _env_example().split("# Google OAuth2", 1)[1].split("# Misc", 1)[0]
assert "HTTPS" in oauth_section
assert "reverse-proxy" in oauth_section
assert "exactly match an authorized redirect URI" in oauth_section
+49
View File
@@ -539,6 +539,55 @@ async def test_pending_agent_draft_routes_do_not_expose_ownerless_rows(tmp_path,
assert rows == [("draft-bob", "agent_draft"), ("draft-ownerless", "agent_draft")]
@pytest.mark.asyncio
async def test_pending_agent_draft_routes_block_cross_owner_actions(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.email_routes as email_routes
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()
conn = sqlite3.connect(db_path)
conn.executemany(
"""
INSERT INTO scheduled_emails
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
VALUES (?, ?, ?, ?, '[]', '9999-12-31T00:00:00', ?, 'agent_draft', ?, ?)
""",
[
("draft-alice", "alice@example.com", "Alice", "alice body", "2026-01-01", "acct-a", "alice"),
("draft-bob", "bob@example.com", "Bob", "bob body", "2026-01-02", "acct-b", "bob"),
],
)
conn.commit()
conn.close()
router = email_routes.setup_email_routes()
list_pending = _route_endpoint(router, "/api/email/pending", "GET")
approve_pending = _route_endpoint(router, "/api/email/pending/{sid}/approve", "POST")
cancel_pending = _route_endpoint(router, "/api/email/pending/{sid}", "DELETE")
alice_rows = await list_pending(owner="alice")
assert [row["id"] for row in alice_rows["pending"]] == ["draft-alice"]
assert (await approve_pending("draft-bob", owner="alice"))["success"] is False
assert (await cancel_pending("draft-bob", owner="alice"))["success"] is False
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT id, status, send_at FROM scheduled_emails ORDER BY id",
).fetchall()
finally:
conn.close()
assert rows == [
("draft-alice", "agent_draft", "9999-12-31T00:00:00"),
("draft-bob", "agent_draft", "9999-12-31T00:00:00"),
]
def test_scheduled_poller_resolves_config_with_row_owner(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.email_pollers as email_pollers
+1 -1
View File
@@ -51,7 +51,7 @@ def test_plan_mode_classifies_every_email_tool():
from src.tool_security import plan_mode_disabled_tools
denied = plan_mode_disabled_tools()
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails"}
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails", "scan_email_unsubscribes"}
for tool in sorted(BUILTIN_EMAIL_TOOLS):
if tool in readonly:
assert tool in PLAN_MODE_READONLY_TOOLS, f"{tool} must be explicit read-only"
+436
View File
@@ -0,0 +1,436 @@
"""Tests for Google OAuth2 support in the /api/email/accounts/test endpoint.
Covers the changes made to routes/email_routes.py:
- test_account_config: OAuth accounts must not require a stored password.
- IMAP and SMTP test paths must use XOAUTH2 for Google accounts.
- Password accounts must still use conn.login() / smtp.login().
These tests use only in-memory SQLite (via SQLAlchemy) and mock network
objects — no live email server or real OAuth credentials are needed.
"""
import time
import unittest.mock as mock
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_orm_db():
"""Return (Session, SessionFactory) backed by an isolated in-memory SQLite DB."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
Factory = sessionmaker(bind=engine)
return Factory(), Factory
def _make_orm_account(session, account_id="acct-1", owner="alice", **kwargs):
from core.database import EmailAccount
row = EmailAccount(
id=account_id,
owner=owner,
name=kwargs.get("name", "Test"),
from_address=kwargs.get("from_address", "me@nia.law"),
imap_host=kwargs.get("imap_host", "imap.gmail.com"),
imap_port=kwargs.get("imap_port", 993),
imap_user=kwargs.get("imap_user", "me@nia.law"),
imap_starttls=kwargs.get("imap_starttls", False),
smtp_host=kwargs.get("smtp_host", "smtp.gmail.com"),
smtp_port=kwargs.get("smtp_port", 587),
smtp_security=kwargs.get("smtp_security", "starttls"),
smtp_user=kwargs.get("smtp_user", "me@nia.law"),
)
for k, v in kwargs.items():
if hasattr(row, k):
setattr(row, k, v)
session.add(row)
session.commit()
return row
# ── test_connection route: OAuth awareness ────────────────────────────────────
@pytest.mark.asyncio
async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
"""The saved-account test must use XOAUTH2 for both mail protocols."""
from src.secret_storage import encrypt as _enc
from routes.email_routes import setup_email_routes
future_expiry = str(int(time.time()) + 7200)
db, Factory = _make_orm_db()
_make_orm_account(
db, account_id="acct-oauth", owner="alice",
oauth_provider="google",
oauth_access_token=_enc("ya29.live"),
oauth_refresh_token=_enc("1//refresh"),
oauth_token_expiry=future_expiry,
)
db.close()
router = setup_email_routes()
test_conn = None
for route in router.routes:
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set()):
test_conn = route.endpoint
break
assert test_conn is not None, "test-connection route not found"
mock_imap_conn = mock.MagicMock()
mock_smtp_conn = mock.MagicMock()
class _FakeReq:
async def json(self):
return {"account_id": "acct-oauth"}
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is True
assert result["imap"].get("ok") is True, \
f"OAuth IMAP test must succeed, got: {result['imap']}"
assert result["smtp"].get("ok") is True, \
f"OAuth SMTP test must succeed, got: {result['smtp']}"
mock_imap_conn.authenticate.assert_called_once()
assert mock_imap_conn.authenticate.call_args[0][0] == "XOAUTH2"
mock_imap_conn.login.assert_not_called()
mock_smtp_conn.auth.assert_called_once()
assert mock_smtp_conn.auth.call_args[0][0] == "XOAUTH2"
mock_smtp_conn.login.assert_not_called()
token_getter.assert_called_once()
@pytest.mark.asyncio
async def test_test_connection_password_account_still_uses_login():
"""Existing password accounts must still go through the login() path."""
from src.secret_storage import encrypt as _enc
from routes.email_routes import setup_email_routes
db, Factory = _make_orm_db()
_make_orm_account(
db, account_id="acct-pw", owner="alice",
imap_host="imap.example.com",
imap_user="me@example.com",
smtp_host="smtp.example.com",
smtp_user="me@example.com",
imap_password=_enc("hunter2"),
)
db.close()
router = setup_email_routes()
test_conn = None
for route in router.routes:
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set()):
test_conn = route.endpoint
break
mock_imap_conn = mock.MagicMock()
mock_smtp_conn = mock.MagicMock()
class _FakeReq:
async def json(self):
return {"account_id": "acct-pw"}
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is True
mock_imap_conn.login.assert_called_once_with("me@example.com", "hunter2")
mock_imap_conn.authenticate.assert_not_called()
mock_smtp_conn.login.assert_called_once_with("me@example.com", "hunter2")
mock_smtp_conn.auth.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
"""Saved Google tokens must never be routed to edited custom hosts."""
from src.secret_storage import encrypt as _enc
from routes.email_routes import setup_email_routes
db, Factory = _make_orm_db()
_make_orm_account(
db,
account_id="acct-oauth",
owner="alice",
oauth_provider="google",
oauth_access_token=_enc("ya29.live"),
oauth_refresh_token=_enc("1//refresh"),
oauth_token_expiry=str(int(time.time()) + 7200),
)
db.close()
router = setup_email_routes()
test_conn = next(
route.endpoint
for route in router.routes
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
)
class _FakeReq:
async def json(self):
return {
"account_id": "acct-oauth",
"imap_host": "collector.invalid",
"smtp_host": "collector.invalid",
}
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is False
assert "imap.gmail.com" in result["imap"]["error"]
assert "smtp.gmail.com" in result["smtp"]["error"]
open_imap.assert_not_called()
open_smtp.assert_not_called()
open_smtp_ssl.assert_not_called()
token_getter.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
"""Google OAuth credentials must not be tested over plaintext transports."""
from src.secret_storage import encrypt as _enc
from routes.email_routes import setup_email_routes
db, Factory = _make_orm_db()
_make_orm_account(
db,
account_id="acct-oauth",
owner="alice",
oauth_provider="google",
oauth_access_token=_enc("ya29.live"),
oauth_refresh_token=_enc("1//refresh"),
oauth_token_expiry=str(int(time.time()) + 7200),
)
db.close()
router = setup_email_routes()
test_conn = next(
route.endpoint
for route in router.routes
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
)
class _FakeReq:
async def json(self):
return {
"account_id": "acct-oauth",
"imap_port": 143,
"imap_starttls": False,
"smtp_port": 587,
"smtp_security": "none",
}
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is False
assert "TLS" in result["imap"]["error"]
assert "TLS" in result["smtp"]["error"]
open_imap.assert_not_called()
open_smtp.assert_not_called()
open_smtp_ssl.assert_not_called()
token_getter.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_does_not_accept_inline_oauth_state():
"""Only an owner-checked saved account may select the OAuth branch."""
from routes.email_routes import setup_email_routes
router = setup_email_routes()
test_conn = next(
route.endpoint
for route in router.routes
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
)
class _FakeReq:
async def json(self):
return {
"imap_host": "imap.gmail.com",
"imap_user": "me@example.com",
"oauth_provider": "google",
"oauth_access_token": "client-supplied-token",
}
with mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is False
assert result["imap"]["error"] == "Need IMAP host, username, and password"
open_imap.assert_not_called()
token_getter.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("imap_starttls", "imap_port"),
[(False, 993), (True, 143)],
)
async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
imap_starttls,
imap_port,
):
"""Both Google IMAP TLS modes receive a certificate-verifying context;
certificate rejection happens before the bearer token is loaded."""
import ssl
from routes.email_routes import setup_email_routes
from src.secret_storage import encrypt as _enc
db, Factory = _make_orm_db()
_make_orm_account(
db,
account_id="acct-imap-tls",
owner="alice",
imap_port=imap_port,
imap_starttls=imap_starttls,
smtp_host="",
oauth_provider="google",
oauth_access_token=_enc("ya29.live"),
oauth_refresh_token=_enc("1//refresh"),
oauth_token_expiry=str(int(time.time()) + 7200),
)
db.close()
router = setup_email_routes()
test_conn = next(
route.endpoint
for route in router.routes
if route.path == "/api/email/accounts/test"
and "POST" in getattr(route, "methods", set())
)
class _FakeReq:
async def json(self):
return {"account_id": "acct-imap-tls"}
context = ssl.create_default_context()
starttls_conn = mock.MagicMock()
starttls_conn.starttls.side_effect = ssl.SSLCertVerificationError(
"untrusted certificate"
)
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_helpers.imaplib.IMAP4",
return_value=starttls_conn,
) as imap_cls, mock.patch(
"routes.email_helpers.imaplib.IMAP4_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as imap_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
) as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is False
assert result["imap"]["ok"] is False
assert context.check_hostname is True
assert context.verify_mode == ssl.CERT_REQUIRED
token_getter.assert_not_called()
if imap_starttls:
assert starttls_conn.starttls.call_args.kwargs["ssl_context"] is context
imap_ssl_cls.assert_not_called()
else:
assert imap_ssl_cls.call_args.kwargs["ssl_context"] is context
imap_cls.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("smtp_security", "smtp_port"),
[("ssl", 465), ("starttls", 587)],
)
async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
smtp_security,
smtp_port,
):
"""Both Google SMTP TLS modes reject an invalid certificate before any
XOAUTH2 credential is obtained or sent."""
import ssl
from routes.email_routes import setup_email_routes
from src.secret_storage import encrypt as _enc
db, Factory = _make_orm_db()
_make_orm_account(
db,
account_id="acct-smtp-tls",
owner="alice",
imap_host="",
smtp_port=smtp_port,
smtp_security=smtp_security,
oauth_provider="google",
oauth_access_token=_enc("ya29.live"),
oauth_refresh_token=_enc("1//refresh"),
oauth_token_expiry=str(int(time.time()) + 7200),
)
db.close()
router = setup_email_routes()
test_conn = next(
route.endpoint
for route in router.routes
if route.path == "/api/email/accounts/test"
and "POST" in getattr(route, "methods", set())
)
class _FakeReq:
async def json(self):
return {"account_id": "acct-smtp-tls"}
context = ssl.create_default_context()
starttls_smtp = mock.MagicMock()
starttls_smtp.starttls.side_effect = ssl.SSLCertVerificationError(
"untrusted certificate"
)
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_routes.smtplib.SMTP",
return_value=starttls_smtp,
) as smtp_cls, mock.patch(
"routes.email_routes.smtplib.SMTP_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as smtp_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
) as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is False
assert result["smtp"]["ok"] is False
assert context.check_hostname is True
assert context.verify_mode == ssl.CERT_REQUIRED
token_getter.assert_not_called()
if smtp_security == "starttls":
assert starttls_smtp.starttls.call_args.kwargs["context"] is context
assert starttls_smtp.close.called
smtp_ssl_cls.assert_not_called()
else:
assert smtp_ssl_cls.call_args.kwargs["context"] is context
smtp_cls.assert_not_called()
+88
View File
@@ -0,0 +1,88 @@
"""Email move/flag must never fall back to sequence-number IMAP ops (#1874 sibling).
`imaplib`'s plain `store()` / `copy()` operate on message SEQUENCE NUMBERS, not
UIDs. `_store_email_flag` / `_move_email_message` (used by the archive / delete /
move / mark endpoints) had an `else` fallback that, when `_uid_exists` returned
False, ran `conn.store(uid, …)` / `conn.copy(uid, …)` + `conn.expunge()` — i.e.
it flagged/copied whichever message occupied sequence position == the UID value
and then permanently expunged it. A stale cached UID (or a server whose UID
probe misbehaves) therefore deleted an unrelated email.
The fix fails safe: when the UID isn't present, return False (callers surface
"Email not found") and never touch a message by sequence number.
This is distinct from #1874, which fixes the auto-spam poller's `_imap_move` in
`routes/email_helpers.py`; this covers the user-facing endpoints in
`routes/email_routes.py`.
"""
import pytest
from routes import email_routes
from routes.email_routes import _store_email_flag, _move_email_message
class _FakeConn:
"""Records IMAP calls. `uid_present` controls the FETCH-UID probe result.
The sequence-number commands (store/copy/expunge) raise if ever called —
the whole point of the fix is that they must not be reached.
"""
def __init__(self, uid_present, uid_move_ok=True):
self.uid_present = uid_present
self.uid_move_ok = uid_move_ok
self.uid_calls = []
self.seqno_calls = []
def uid(self, command, *args):
self.uid_calls.append((command.upper(), args))
cmd = command.upper()
if cmd == "FETCH":
return ("OK", [b"1 (UID 5031)"] if self.uid_present else [])
if cmd == "MOVE":
return ("OK" if self.uid_move_ok else "NO", [b""])
if cmd in ("COPY", "STORE"):
return ("OK", [b""])
return ("OK", [b""])
# Sequence-number APIs — must never be used with a UID.
def store(self, *a):
self.seqno_calls.append(("store", a)); return ("OK", [b""])
def copy(self, *a):
self.seqno_calls.append(("copy", a)); return ("OK", [b""])
def expunge(self, *a):
self.seqno_calls.append(("expunge", a)); return ("OK", [b""])
@pytest.fixture(autouse=True)
def _no_folder_resolution(monkeypatch):
# _move_email_message resolves the destination folder via the connection;
# short-circuit it so the test focuses on the UID-vs-seqno behaviour.
monkeypatch.setattr(email_routes, "_resolve_mail_folder", lambda conn, dest, role="": dest)
def test_store_flag_missing_uid_fails_safe():
conn = _FakeConn(uid_present=False)
assert _store_email_flag(conn, "5031", "\\Deleted", add=True) is False
assert conn.seqno_calls == [] # never touched a message by sequence number
def test_move_missing_uid_fails_safe():
conn = _FakeConn(uid_present=False)
assert _move_email_message(conn, "5031", "Trash", role="trash") is False
assert conn.seqno_calls == [] # no copy/store/expunge on a phantom seqno
def test_store_flag_present_uid_uses_uid_store():
conn = _FakeConn(uid_present=True)
assert _store_email_flag(conn, "5031", "\\Seen", add=True) is True
assert any(c[0] == "STORE" for c in conn.uid_calls)
assert conn.seqno_calls == []
def test_move_present_uid_uses_uid_move():
conn = _FakeConn(uid_present=True, uid_move_ok=True)
assert _move_email_message(conn, "5031", "Archive", role="archive") is True
assert any(c[0] == "MOVE" for c in conn.uid_calls)
assert conn.seqno_calls == []
@@ -0,0 +1,85 @@
from email.message import EmailMessage
from routes.email_routes import (
_dedupe_unsubscribe_candidates,
_email_unsubscribe_candidate_from_msg,
_parse_list_unsubscribe_header,
)
def test_parse_list_unsubscribe_mailto_and_url():
methods = _parse_list_unsubscribe_header(
'<mailto:list@example.com?subject=unsubscribe&body=remove%20me>, '
'<https://example.com/unsubscribe/token>'
)
assert methods == [
{
"kind": "mailto",
"target": "list@example.com",
"subject": "unsubscribe",
"body": "remove me",
"executable": True,
},
{
"kind": "url",
"target": "https://example.com/unsubscribe/token",
"executable": False,
},
]
def test_unsubscribe_candidate_requires_unsubscribe_header():
msg = EmailMessage()
msg["From"] = "Shop <deals@example.com>"
msg["Subject"] = "Limited time discount"
msg["Precedence"] = "bulk"
assert _email_unsubscribe_candidate_from_msg(msg, "12", "INBOX") is None
def test_unsubscribe_candidate_scores_bulk_newsletter():
msg = EmailMessage()
msg["From"] = "Shop <deals@example.com>"
msg["Subject"] = "Limited time discount"
msg["Precedence"] = "bulk"
msg["List-Id"] = "Shop Deals <deals.example.com>"
msg["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
candidate = _email_unsubscribe_candidate_from_msg(
msg,
"12",
"INBOX",
spam_cached={"spam": True, "reason": "marketing blast"},
)
assert candidate is not None
assert candidate["uid"] == "12"
assert candidate["can_execute"] is True
assert candidate["recommended_method"]["kind"] == "mailto"
assert "marketing blast" in candidate["reasons"]
def test_dedupe_unsubscribe_candidates_collapses_same_list():
first = EmailMessage()
first["From"] = "Shop <deals@example.com>"
first["Subject"] = "Sale one"
first["List-Id"] = "Shop Deals <deals.example.com>"
first["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
second = EmailMessage()
second["From"] = "Shop <deals@example.com>"
second["Subject"] = "Sale two"
second["List-Id"] = "Shop Deals <deals.example.com>"
second["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
candidates = [
_email_unsubscribe_candidate_from_msg(first, "12", "INBOX"),
_email_unsubscribe_candidate_from_msg(second, "13", "INBOX"),
]
deduped = _dedupe_unsubscribe_candidates(candidates)
assert len(deduped) == 1
assert deduped[0]["duplicate_count"] == 2
assert deduped[0]["duplicate_uids"] == ["12", "13"]
+1 -1
View File
@@ -358,7 +358,7 @@ def test_compare_start_rejects_unowned_endpoint_id(monkeypatch):
def test_compare_endpoint_key_lookup_is_owner_scoped():
body = Path("routes/compare_routes.py").read_text(encoding="utf-8")
body = Path("routes/compare/compare_routes.py").read_text(encoding="utf-8")
start_body = body.split("def start_comparison", 1)[1].split("# Store comparison record", 1)[0]
helper_body = body.split("def _owned_endpoint_by_url", 1)[1].split("class RecordVoteRequest", 1)[0]
id_helper_body = body.split("def _owned_endpoint_by_id", 1)[1].split("class RecordVoteRequest", 1)[0]
@@ -0,0 +1,31 @@
"""Harden hwfit model-catalog parsing against non-string field values.
`params_b` and `is_prequantized` read free-form fields straight off the HF
catalog JSON. `parameter_count` is normally a string like "7B" and
`quantization` a string like "FP8", but a catalog row can carry a non-string
(e.g. an integer parameter_count, or a null/number quantization). The code
called `pc.strip()` / `q.startswith(...)` directly, so one such row raised
AttributeError and aborted the whole ranking pass (params_b/is_prequantized
run for every model). Non-strings are now treated as unknown.
"""
from services.hwfit.models import params_b, is_prequantized
def test_params_b_nonstring_count_does_not_raise():
assert params_b({"parameter_count": 7}) == 0.0
assert params_b({"parameter_count": ["7B"]}) == 0.0
def test_params_b_valid_count_still_parses():
assert params_b({"parameter_count": "7B"}) == 7.0
assert params_b({"parameters_raw": 7_000_000_000}) == 7.0
def test_is_prequantized_nonstring_quantization_does_not_raise():
assert is_prequantized({"quantization": 8}) is False
assert is_prequantized({"name": "plain-model", "quantization": 123}) is False
def test_is_prequantized_still_detects_real_markers():
assert is_prequantized({"name": "some-model-awq"}) is True
assert is_prequantized({"quantization": "FP8-Mixed"}) is True
+7 -2
View File
@@ -1,7 +1,12 @@
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
from services.hwfit import image_models
rank_image_models = image_models.rank_image_models
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
def test_rank_image_models_handles_non_dict_system():
def test_rank_image_models_handles_non_dict_system(monkeypatch):
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
# `system` is the detected-hardware dict; if detection failed and returned
# None (or a non-dict), system.get(...) raised AttributeError. Treat a
# non-dict system as "unknown hardware" (no GPU) rather than crashing.
+130 -3
View File
@@ -1,15 +1,142 @@
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
from services.hwfit import image_models
rank_image_models = image_models.rank_image_models
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
SYS = {"gpu_vram_gb": 0, "has_gpu": False}
def test_rank_image_models_handles_non_string_search():
def _disable_hf_discovery(monkeypatch):
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
def test_rank_image_models_handles_non_string_search(monkeypatch):
_disable_hf_discovery(monkeypatch)
# search is a CLI/API filter arg; a non-string made search.lower() raise
# AttributeError. A non-string search should behave as "no filter".
out = rank_image_models(SYS, search=123)
assert len(out) == len(IMAGE_MODEL_REGISTRY)
def test_rank_image_models_string_filter_still_applies():
def test_rank_image_models_string_filter_still_applies(monkeypatch):
_disable_hf_discovery(monkeypatch)
out = rank_image_models(SYS, search="zzzznotarealmodelzzz")
assert out == []
def test_rank_image_models_uses_ram_budget_when_gpu_disabled(monkeypatch):
model = {
"id": "example-org/example-image-model",
"name": "Example Image Model",
"provider": "example-org",
"params_b": 20.0,
"vram_bf16": 42.0,
"vram_fp8": 22.0,
"vram_q4": 14.0,
"default_quant": "FP8",
"quant_repos": {},
"capabilities": ["text-to-image"],
"description": "Imported from test fixture.",
"quality": 80,
"speed": 50,
}
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
gpu_out = rank_image_models({"has_gpu": True, "gpu_vram_gb": 8, "available_ram_gb": 64}, search="Example Image")
ram_out = rank_image_models({"has_gpu": False, "gpu_vram_gb": 0, "available_ram_gb": 64}, search="Example Image")
gpu_model = next(m for m in gpu_out if m["id"] == "example-org/example-image-model")
ram_model = next(m for m in ram_out if m["id"] == "example-org/example-image-model")
assert gpu_model["fit"] == "no_fit"
assert gpu_model["quant"] == "FP8"
assert gpu_model["fit_budget"] == "gpu"
assert ram_model["fit"] in {"good", "perfect"}
assert ram_model["quant"] == "BF16"
assert ram_model["fit_budget"] == "ram"
def test_mlx_image_collection_models_only_show_on_apple(monkeypatch):
mlx_model = {
"id": "mlx-community/example-apple-image-model",
"name": "Example Apple Image Model",
"provider": "mlx-community",
"params_b": 4.0,
"vram_bf16": 10.0,
"vram_fp8": None,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {},
"capabilities": ["text-to-image"],
"description": "Apple Silicon / MLX only.",
"quality": 82,
"speed": 88,
"mlx_only": True,
}
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
cuda = rank_image_models({"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"}, search="Example Apple")
metal = rank_image_models(
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
search="Example Apple",
)
assert cuda == []
assert [m["id"] for m in metal] == ["mlx-community/example-apple-image-model"]
def test_apple_image_mode_hides_non_mlx_models(monkeypatch):
model = {
"id": "example-org/example-image-model",
"name": "Example Image Model",
"provider": "example-org",
"params_b": 4.0,
"vram_bf16": 8.0,
"vram_fp8": None,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {},
"capabilities": ["text-to-image"],
"description": "Imported from test fixture.",
"quality": 80,
"speed": 80,
}
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
metal = rank_image_models(
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
search="Example Image",
)
cuda = rank_image_models(
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"},
search="Example Image",
)
assert metal == []
assert [m["id"] for m in cuda] == ["example-org/example-image-model"]
def test_mlx_collection_imports_show_on_metal_not_cuda(monkeypatch):
mlx_model = image_models._collection_item_to_model(
{"id": "mlx-community/example-image-model-4bit"},
"Example Apple image collection",
mlx_only=True,
)
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
metal = rank_image_models(
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "metal", "unified_memory": True},
search="example-image-model",
)
cuda = rank_image_models(
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "cuda"},
search="example-image-model",
)
assert [m["id"] for m in metal] == ["mlx-community/example-image-model-4bit"]
assert cuda == []
+108
View File
@@ -0,0 +1,108 @@
"""Regression: IMAP calls must use uid() not search()/fetch().
conn.search() / conn.fetch() operate on volatile positional sequence
numbers that shift whenever messages are deleted or expunged. The
sig-learner and daily-brief actions must use conn.uid("SEARCH", ...)
and conn.uid("FETCH", ...) which address messages by their persistent
RFC 3501 UID (§2.3.1.1, §6.4.8).
"""
import pytest
class _SpyImap:
"""IMAP stub that records uid() calls and raises on search()/fetch()."""
def __init__(self, uid_list=b"1 2 3"):
self._uid_list = uid_list
self.uid_calls: list[tuple] = []
def select(self, *args, **kwargs):
return "OK", []
def uid(self, command, *args):
self.uid_calls.append((command,) + args)
if command == "SEARCH":
return "OK", [self._uid_list]
if command == "FETCH":
query = args[1] if len(args) > 1 else ""
if "HEADER.FIELDS" in query:
return "OK", [(None, b"From: Writer <writer@example.com>\r\n"
b"Subject: Hello\r\n\r\n")]
return "OK", [(None, b"Body text\r\n\r\nRegards,\r\nThe Writer\r\n")]
return "OK", []
def search(self, *args):
raise AssertionError("conn.search() called — must use conn.uid('SEARCH', ...) instead")
def fetch(self, *args):
raise AssertionError("conn.fetch() called — must use conn.uid('FETCH', ...) instead")
def logout(self):
pass
@pytest.mark.asyncio
async def test_sig_learner_uses_uid_search(monkeypatch):
"""_pull_headers must call conn.uid('SEARCH', ...) not conn.search()."""
from routes import email_helpers
from src import task_endpoint
from src.builtin_actions import action_learn_sender_signatures
spy = _SpyImap()
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", lambda *a, **kw: [])
message, ok = await action_learn_sender_signatures("alice")
assert ok is False # no LLM candidates — stops before LLM, after IMAP
assert any(c[0] == "SEARCH" for c in spy.uid_calls), "uid('SEARCH', ...) was not called"
@pytest.mark.asyncio
async def test_sig_learner_uses_uid_fetch(monkeypatch):
"""_pull_headers must call conn.uid('FETCH', ...) not conn.fetch()."""
from routes import email_helpers
from src import task_endpoint
from src.builtin_actions import action_learn_sender_signatures
spy = _SpyImap()
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", lambda *a, **kw: [])
await action_learn_sender_signatures("alice")
assert any(c[0] == "FETCH" for c in spy.uid_calls), "uid('FETCH', ...) was not called"
@pytest.mark.asyncio
async def test_daily_brief_uses_uid_commands(monkeypatch):
"""action_daily_brief email section must use uid() not search()/fetch()."""
from core import database
from core import auth as _auth_mod
from routes import email_helpers
from src.builtin_actions import action_daily_brief
class _Q:
def filter(self, *a, **kw): return self
def join(self, *a, **kw): return self
def order_by(self, *a): return self
def all(self): return []
class _Db:
def query(self, *a): return _Q()
def close(self): pass
class _FakeAuth:
is_configured = False
monkeypatch.setattr(database, "SessionLocal", _Db)
monkeypatch.setattr(_auth_mod, "AuthManager", lambda: _FakeAuth())
spy = _SpyImap(uid_list=b"10 20 30")
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
message, ok = await action_daily_brief("")
assert ok is True
assert any(c[0] == "SEARCH" for c in spy.uid_calls), "uid('SEARCH', ...) was not called"
assert any(c[0] == "FETCH" for c in spy.uid_calls), "uid('FETCH', ...) was not called"
+154 -11
View File
@@ -1,4 +1,7 @@
"""Kimi Code User-Agent fallback list and 403 detection."""
import pytest
from src import llm_core
from src.llm_core import (
KIMI_CODE_USER_AGENTS,
KIMI_CODE_USER_AGENT,
@@ -12,6 +15,35 @@ from src.llm_core import (
)
KIMI_CHAT_URL = "https://api.kimi.com/coding/v1/chat/completions"
class _Resp:
def __init__(self, status, text="{}"):
self.status_code = status
self.content = text.encode()
self.text = text
class _FakeStreamResp(_Resp):
async def aiter_lines(self):
yield "data: [DONE]"
async def aread(self):
return b""
class _FakeStreamCtx:
def __init__(self, response):
self.response = response
async def __aenter__(self):
return self.response
async def __aexit__(self, *args):
return False
class TestKimiCodeUserAgents:
def test_default_is_first_fallback(self):
assert KIMI_CODE_USER_AGENT == KIMI_CODE_USER_AGENTS[0]
@@ -29,9 +61,8 @@ class TestKimiCodeUserAgents:
def test_ua_candidates_prefers_cache(self):
_kimi_code_ua_cache.clear()
url = "https://api.kimi.com/coding/v1/chat/completions"
_remember_kimi_code_user_agent(url, "Kilo-Code/1.0")
candidates = _kimi_code_ua_candidates(url)
_remember_kimi_code_user_agent(KIMI_CHAT_URL, "Kilo-Code/1.0")
candidates = _kimi_code_ua_candidates(KIMI_CHAT_URL)
assert candidates[0] == "Kilo-Code/1.0"
assert len(candidates) == len(KIMI_CODE_USER_AGENTS)
_kimi_code_ua_cache.clear()
@@ -48,22 +79,134 @@ class TestKimiCodeUserAgents:
_kimi_code_ua_cache.clear()
calls = []
class _Resp:
def __init__(self, status, text=""):
self.status_code = status
self.content = text.encode()
self.text = text
def fake_post(url, headers=None, **kwargs):
calls.append(headers.get("User-Agent"))
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
return _Resp(200, "{}")
monkeypatch.setattr(llm_core.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(RuntimeError()))
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
url = "https://api.kimi.com/coding/v1/chat/completions"
r = httpx_post_kimi_aware(url, {"Authorization": "Bearer x"}, json={})
r = httpx_post_kimi_aware(KIMI_CHAT_URL, {"Authorization": "Bearer x"}, json={})
assert r.status_code == 200
assert calls[0] == KIMI_CODE_USER_AGENTS[0]
assert calls[1] == KIMI_CODE_USER_AGENTS[1]
_kimi_code_ua_cache.clear()
@pytest.mark.asyncio
async def test_async_post_uses_async_probe_not_sync_httpx_get(self, monkeypatch):
_kimi_code_ua_cache.clear()
class FakeClient:
def __init__(self):
self.get_user_agents = []
self.post_user_agents = []
async def get(self, url, headers=None, **kwargs):
self.get_user_agents.append(headers.get("User-Agent"))
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
return _Resp(200)
async def post(self, url, headers=None, **kwargs):
self.post_user_agents.append(headers.get("User-Agent"))
return _Resp(200)
def forbidden_sync_get(*args, **kwargs):
raise AssertionError("async Kimi path must not call sync httpx.get")
client = FakeClient()
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
r = await llm_core.httpx_post_kimi_aware_async(
client,
KIMI_CHAT_URL,
{"Authorization": "Bearer x"},
json={},
)
assert r.status_code == 200
assert client.get_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
assert client.post_user_agents == [KIMI_CODE_USER_AGENTS[1]]
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
_kimi_code_ua_cache.clear()
@pytest.mark.asyncio
async def test_async_post_preserves_fallback_when_probe_fails(self, monkeypatch):
_kimi_code_ua_cache.clear()
class FakeClient:
def __init__(self):
self.post_user_agents = []
async def get(self, url, headers=None, **kwargs):
raise RuntimeError("models probe unavailable")
async def post(self, url, headers=None, **kwargs):
self.post_user_agents.append(headers.get("User-Agent"))
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
return _Resp(200)
def forbidden_sync_get(*args, **kwargs):
raise AssertionError("async Kimi path must not call sync httpx.get")
client = FakeClient()
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
r = await llm_core.httpx_post_kimi_aware_async(
client,
KIMI_CHAT_URL,
{"Authorization": "Bearer x"},
json={},
)
assert r.status_code == 200
assert client.post_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
_kimi_code_ua_cache.clear()
@pytest.mark.asyncio
async def test_stream_uses_async_kimi_probe_not_sync_httpx_get(self, monkeypatch):
_kimi_code_ua_cache.clear()
class FakeClient:
def __init__(self):
self.get_user_agents = []
self.stream_headers = []
async def get(self, url, headers=None, **kwargs):
self.get_user_agents.append(headers.get("User-Agent"))
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
return _Resp(200)
def stream(self, method, url, **kwargs):
self.stream_headers.append(kwargs.get("headers") or {})
return _FakeStreamCtx(_FakeStreamResp(200))
def forbidden_sync_get(*args, **kwargs):
raise AssertionError("streaming Kimi path must not call sync httpx.get")
client = FakeClient()
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *args, **kwargs: None)
chunks = [
chunk
async for chunk in llm_core.stream_llm(
KIMI_CHAT_URL,
"kimi-for-coding",
[{"role": "user", "content": "hi"}],
headers={"Authorization": "Bearer x"},
)
]
assert chunks == ["data: [DONE]\n\n"]
assert client.get_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
assert client.stream_headers[0]["User-Agent"] == KIMI_CODE_USER_AGENTS[1]
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
_kimi_code_ua_cache.clear()
+1 -1
View File
@@ -92,7 +92,7 @@ def test_strips_every_named_email_tool_fence():
email_tools = [
"list_email_accounts", "send_email", "list_emails", "read_email",
"reply_to_email", "bulk_email", "archive_email", "delete_email",
"mark_email_read",
"mark_email_read", "scan_email_unsubscribes", "unsubscribe_email",
]
for tool in email_tools:
fence = f"```{tool}\n{{}}\n```"
+141
View File
@@ -8,6 +8,8 @@ works while a different model silently answers).
import json
import asyncio
import pytest
from src import llm_core
@@ -55,6 +57,145 @@ def test_no_fallback_event_when_primary_succeeds(monkeypatch):
assert not any('"fallback"' in c for c in chunks)
def test_done_only_primary_invokes_fallback(monkeypatch):
calls = []
def per_model(model):
calls.append(model)
if model == "primary":
return ["data: [DONE]\n\n"]
return [
'data: {"type": "model_actual", "requested_model": "backup", "model": "backup-v2"}\n\n',
'data: {"delta": "backup answer"}\n\n',
"data: [DONE]\n\n",
]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
assert any('"delta": "backup answer"' in c for c in chunks)
model_idx = next(i for i, c in enumerate(chunks) if '"model_actual"' in c)
fallback_idx = next(i for i, c in enumerate(chunks) if '"fallback"' in c)
answer_idx = next(i for i, c in enumerate(chunks) if '"delta": "backup answer"' in c)
assert fallback_idx < model_idx < answer_idx
def test_usage_then_done_primary_invokes_fallback_and_discards_usage(monkeypatch):
calls = []
def per_model(model):
calls.append(model)
if model == "primary":
return [
'data: {"type": "usage", "data": {"input_tokens": 4, "output_tokens": 0}}\n\n',
"data: [DONE]\n\n",
]
return ['data: {"delta": "backup answer"}\n\n', "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
assert not any('"type": "usage"' in c for c in chunks)
@pytest.mark.parametrize(
"output_chunk",
[
'data: {"delta": "visible text"}\n\n',
'data: {"delta": "reasoning", "thinking": true}\n\n',
],
)
def test_text_or_reasoning_output_prevents_fallback(monkeypatch, output_chunk):
calls = []
def per_model(model):
calls.append(model)
return [output_chunk, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary"]
assert output_chunk in chunks
assert not any('"fallback"' in c for c in chunks)
def test_whitespace_only_delta_prevents_fallback(monkeypatch):
calls = []
whitespace = 'data: {"delta": " "}\n\n'
def per_model(model):
calls.append(model)
return [whitespace, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary"]
assert whitespace in chunks
assert not any('"fallback"' in c for c in chunks)
def test_completed_tool_call_output_prevents_fallback(monkeypatch):
calls = []
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "bash", "arguments": "{}"}]}\n\n'
def per_model(model):
calls.append(model)
return [tool_calls, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary"]
assert tool_calls in chunks
assert not any('"fallback"' in c for c in chunks)
def test_tool_call_delta_is_forwarded_immediately_and_prevents_fallback(monkeypatch):
calls = []
advanced_past_delta = False
tool_delta = 'data: {"type": "tool_call_delta", "index": 0, "arg_delta": "{\\"path\\":"}\n\n'
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "write_file", "arguments": "{\\"path\\":\\"x\\"}"}]}\n\n'
async def fake_stream(url, model, messages, **kw):
nonlocal advanced_past_delta
calls.append(model)
yield tool_delta
advanced_past_delta = True
yield tool_calls
yield "data: [DONE]\n\n"
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
async def run():
stream = llm_core.stream_llm_with_fallback(
[("u1", "primary", {}), ("u2", "backup", {})],
[{"role": "user", "content": "hi"}],
)
first = await anext(stream)
assert first == tool_delta
assert not advanced_past_delta
chunks = [first]
async for chunk in stream:
chunks.append(chunk)
return chunks
chunks = asyncio.run(run())
assert calls == ["primary"]
assert tool_calls in chunks
assert not any('"type": "fallback"' in c for c in chunks)
def test_empty_final_candidate_surfaces_terminal_error(monkeypatch):
calls = []
def per_model(model):
calls.append(model)
if model == "primary":
return [] # clean EOF without substantive output
return ["data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
errors = [c for c in chunks if c.startswith("event: error")]
assert len(errors) == 1
assert "All model candidates returned no substantive output" in errors[0]
assert '"status": 502' in errors[0]
def test_dedupe_candidates_keeps_first_of_each_route():
"""(url, model) is the route key; later repeats are dropped, order preserved,
the first tuple (with its headers) kept, malformed entries filtered."""
@@ -0,0 +1,73 @@
from src import llm_core
def _tool():
return {
"type": "function",
"function": {
"name": "search",
"description": "search",
"parameters": {"type": "object", "properties": {}},
},
}
def test_openai_chat_tools_force_gpt5_reasoning_effort_none():
payload = {"tools": [_tool()], "reasoning_effort": "high"}
llm_core._scrub_openai_chat_tool_reasoning(
payload,
"https://api.openai.com/v1/chat/completions",
"gpt-5.6-luna",
)
assert payload["reasoning_effort"] == "none"
def test_openai_chat_tools_match_gpt5_variants():
for model in ["gpt-5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "openai/gpt-5.6-luna"]:
payload = {"tools": [_tool()], "reasoning_effort": "medium"}
llm_core._scrub_openai_chat_tool_reasoning(
payload,
"https://api.openai.com/v1/chat/completions",
model,
)
assert payload["reasoning_effort"] == "none"
def test_openai_chat_no_tools_leaves_reasoning_effort_unchanged():
payload = {"reasoning_effort": "high"}
llm_core._scrub_openai_chat_tool_reasoning(
payload,
"https://api.openai.com/v1/chat/completions",
"gpt-5.6-luna",
)
assert payload["reasoning_effort"] == "high"
def test_non_openai_host_leaves_reasoning_effort_unchanged():
payload = {"tools": [_tool()], "reasoning_effort": "high"}
llm_core._scrub_openai_chat_tool_reasoning(
payload,
"https://openrouter.ai/api/v1/chat/completions",
"openai/gpt-5.6-luna",
)
assert payload["reasoning_effort"] == "high"
def test_non_gpt5_model_leaves_reasoning_effort_unchanged():
payload = {"tools": [_tool()], "reasoning_effort": "high"}
llm_core._scrub_openai_chat_tool_reasoning(
payload,
"https://api.openai.com/v1/chat/completions",
"gpt-4.1",
)
assert payload["reasoning_effort"] == "high"
+2 -2
View File
@@ -91,14 +91,14 @@ def test_local_minimax_mlx_payload_gets_stability_defaults(monkeypatch):
monkeypatch.setattr(model_context, "is_local_endpoint", lambda _url: True)
payload = {
"model": "cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
"model": "example-org/MiniMax-M2.7-BF16-mlx-4Bit",
"temperature": 0.9,
}
llm_core._apply_local_generation_stability(
payload,
"http://192.168.1.22:8091/v1/chat/completions",
"cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
"example-org/MiniMax-M2.7-BF16-mlx-4Bit",
)
assert payload["temperature"] == 0.2
+139
View File
@@ -0,0 +1,139 @@
"""manage_tasks mutations must fail closed on owner-less / cross-owner tasks.
The edit/delete/pause/run actions of ``do_manage_tasks`` previously gated with
``if owner and task.owner and task.owner != owner``. The middle term made the
check a no-op whenever the task had no owner — the state a scheduled task is in
when it was created in no-login mode (or via the localhost middleware bypass)
before the periodic legacy-owner sweep reassigns it to the admin user. So any
authenticated user's agent could edit, delete, pause, or *run* another tenant's
owner-less task. The sibling ``list`` action already scopes with an exact
``ScheduledTask.owner == owner`` filter, so the mutators were strictly more
permissive than the reader.
"""
import json
import tempfile
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb
from core.database import ScheduledTask
from src.tools.system import do_manage_tasks
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
# do_manage_tasks does `from core.database import SessionLocal` at call time,
# so patching the module attribute is enough to point it at the temp DB.
cdb.SessionLocal = _TS
def _seed(task_id, owner):
db = _TS()
try:
db.add(ScheduledTask(
id=task_id, owner=owner, name=task_id, prompt="original",
task_type="llm", trigger_type="webhook", status="active",
output_target="session",
))
db.commit()
finally:
db.close()
def _get(task_id):
db = _TS()
try:
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
finally:
db.close()
@pytest.mark.asyncio
async def test_edit_denied_on_ownerless_task_for_authenticated_user():
_seed("ownerless-edit", None)
out = await do_manage_tasks(
json.dumps({"action": "edit", "task_id": "ownerless-edit", "prompt": "pwned"}),
owner="alice",
)
assert out["exit_code"] == 1 and out["error"] == "Access denied"
assert _get("ownerless-edit").prompt == "original"
@pytest.mark.asyncio
async def test_delete_denied_on_ownerless_task_for_authenticated_user():
_seed("ownerless-del", None)
out = await do_manage_tasks(
json.dumps({"action": "delete", "task_id": "ownerless-del"}),
owner="alice",
)
assert out["exit_code"] == 1 and out["error"] == "Access denied"
assert _get("ownerless-del") is not None
@pytest.mark.asyncio
async def test_pause_denied_on_ownerless_task_for_authenticated_user():
_seed("ownerless-pause", None)
out = await do_manage_tasks(
json.dumps({"action": "pause", "task_id": "ownerless-pause"}),
owner="alice",
)
assert out["exit_code"] == 1 and out["error"] == "Access denied"
assert _get("ownerless-pause").status == "active"
@pytest.mark.asyncio
async def test_run_denied_on_ownerless_task_for_authenticated_user():
_seed("ownerless-run", None)
out = await do_manage_tasks(
json.dumps({"action": "run", "task_id": "ownerless-run"}),
owner="alice",
)
assert out["exit_code"] == 1 and out["error"] == "Access denied"
@pytest.mark.asyncio
async def test_edit_denied_on_other_owners_task():
_seed("bob-task", "bob")
out = await do_manage_tasks(
json.dumps({"action": "edit", "task_id": "bob-task", "prompt": "pwned"}),
owner="alice",
)
assert out["exit_code"] == 1 and out["error"] == "Access denied"
assert _get("bob-task").prompt == "original"
@pytest.mark.asyncio
async def test_edit_allowed_for_matching_owner():
_seed("alice-task", "alice")
out = await do_manage_tasks(
json.dumps({"action": "edit", "task_id": "alice-task", "prompt": "updated"}),
owner="alice",
)
assert out["exit_code"] == 0
assert _get("alice-task").prompt == "updated"
@pytest.mark.asyncio
async def test_edit_allowed_in_no_login_mode():
# owner is None when auth is disabled — single-user mode keeps full access
# to shared (owner-less) tasks, exactly as `list` returns them unfiltered.
_seed("shared-task", None)
out = await do_manage_tasks(
json.dumps({"action": "edit", "task_id": "shared-task", "prompt": "updated"}),
owner=None,
)
assert out["exit_code"] == 0
assert _get("shared-task").prompt == "updated"
+43 -2
View File
@@ -18,12 +18,24 @@ def node_available():
pytest.skip("node binary not on PATH")
def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)"):
def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)", with_katex: bool = False):
script = textwrap.dedent(
r"""
import fs from 'node:fs';
globalThis.window = { location: { origin: 'http://localhost' }, katex: null };
if (__WITH_KATEX__) {
// Minimal stand-in for the CDN katex global: wraps the source so tests
// can assert what was (or wasn't) handed to KaTeX.
const katexStub = {
renderToString(src, opts) {
const display = !!(opts && opts.displayMode);
return `<span class="katex" data-display="${display}">${src}</span>`;
},
};
globalThis.window.katex = katexStub;
globalThis.katex = katexStub;
}
globalThis.document = {
readyState: 'loading',
addEventListener() {},
@@ -77,7 +89,9 @@ def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)"):
const input = JSON.parse(process.argv[1]);
console.log(JSON.stringify({ html: __RENDER_EXPR__ }));
"""
).replace("__RENDER_EXPR__", render_expr)
).replace("__RENDER_EXPR__", render_expr).replace(
"__WITH_KATEX__", "true" if with_katex else "false"
)
result = subprocess.run(
["node", "--input-type=module", "-e", script, json.dumps(markdown)],
cwd=_REPO,
@@ -200,6 +214,33 @@ def test_inline_code_content_is_html_escaped(node_available):
assert "<b>" not 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
# closing $ is preceded by a space and followed by a digit.
html = _run_markdown_case(
"The price rose from $5 to $10 overnight.", with_katex=True
)
assert 'class="katex"' not in html
assert "$5" in html
assert "$10" in html
def test_inline_math_still_renders_through_katex(node_available):
html = _run_markdown_case("Pythagoras: $x^2 + y^2 = z^2$ holds.", with_katex=True)
assert '<span class="katex" data-display="false">x^2 + y^2 = z^2</span>' in html
assert "$" not in html
def test_display_math_still_renders_through_katex(node_available):
html = _run_markdown_case("$$\\frac{a}{b}$$", with_katex=True)
assert 'data-display="true"' in html
assert "$$" not in html
def test_dotted_python_import_paths_are_not_autolinked(node_available):
html = _run_markdown_case(
"from imblearn.combine import SMOTETomek\n"
+47
View File
@@ -0,0 +1,47 @@
"""Pin _matchesCombo (static/js/keyboard-shortcuts.js) against a non-string
keybind. Driven through `node --input-type=module` (same approach as
tests/test_markdown_table_row_js.py); skips when `node` is missing.
Regression: keybinds are merged from the server response of
`/api/auth/settings` (`{ ..._defaultKeybinds, ...s.keybinds }`). A corrupt
or malformed `keybinds` value (e.g. a number instead of "ctrl+k") reached
`combo.split('+')` and threw "combo.split is not a function", breaking the
whole keydown handler. The guard treats any non-string combo as "no match".
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MOD = _REPO / "static" / "js" / "keyboard-shortcuts.js"
_HAS_NODE = shutil.which("node") is not None
_EVENT = "{key:'k',ctrlKey:false,altKey:false,shiftKey:false,metaKey:false}"
def _match(combo_js):
js = f"""
import {{ _matchesCombo }} from '{_MOD.as_posix()}';
console.log(JSON.stringify(_matchesCombo({_EVENT}, {combo_js})));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_non_string_combo_is_no_match():
assert _match("123") is False
assert _match("{}") is False
assert _match("null") is False
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_matching_combo_still_fires():
assert _match("'k'") is True
+51
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from src import mcp_oauth
@@ -79,3 +80,53 @@ def test_db_token_storage_round_trip():
t = asyncio.run(go())
assert t.access_token == "abc"
assert srv.oauth_tokens is not None # persisted as JSON
def _fake_storage(oauth_tokens):
class FakeSrv:
pass
srv = FakeSrv()
srv.oauth_tokens = oauth_tokens
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def commit(self):
pass
def close(self):
pass
return srv, mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
def test_load_falls_back_to_empty_dict_for_non_dict_json():
# A corrupted/migrated oauth_tokens column holding a JSON array, not an
# object, must not crash _load()'s callers with AttributeError.
_srv, storage = _fake_storage('["stale", "data"]')
assert storage._load() == {}
def test_get_tokens_returns_none_for_non_dict_oauth_tokens():
_srv, storage = _fake_storage("42")
async def go():
return await storage.get_tokens()
assert asyncio.run(go()) is None
def test_update_recovers_from_non_dict_oauth_tokens():
# _update() must not raise TypeError trying to item-assign into a list.
srv, storage = _fake_storage('["stale", "data"]')
storage._update("tokens", {"access_token": "new"})
assert json.loads(srv.oauth_tokens) == {"tokens": {"access_token": "new"}}
+46
View File
@@ -0,0 +1,46 @@
"""cmd_add (scripts/odysseus-memory) must tolerate a non-dict row in the
existing store. Every other command funnels load_all() through
`_memory_entries()` (which drops non-dicts), but cmd_add iterated the raw
list in its dedup check: `any(e.get("id") == ... for e in all_entries)`
crashed with AttributeError on a corrupt/hand-edited memory.json row that
is not a dict. The isinstance check short-circuits before `.get`.
"""
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1]
def _load_cli(monkeypatch):
svc = types.ModuleType("services.memory.memory")
svc.MemoryManager = MagicMock()
monkeypatch.setitem(sys.modules, "services.memory.memory", svc)
path = ROOT / "scripts" / "odysseus-memory"
loader = importlib.machinery.SourceFileLoader("odysseus_memory_cli_add", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def test_cmd_add_tolerates_non_dict_existing_row(monkeypatch):
cli = _load_cli(monkeypatch)
cli._mgr = MagicMock()
cli._mgr.add_entry.return_value = {"id": "m2", "text": "new"}
cli._mgr.load_all.return_value = [
{"id": "m1", "text": "existing"},
"corrupt-row",
None,
]
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
cli.cmd_add(SimpleNamespace(text="new", category="fact", owner=None))
assert emitted == [{"id": "m2", "text": "new"}]
cli._mgr.save.assert_called_once()
+248
View File
@@ -0,0 +1,248 @@
import src.model_capabilities as mc
def surfaces(capability):
return set(mc.display_surfaces_for(capability))
def test_endpoint_type_llm_maps_to_explicit_chat_capability():
capability = mc.capability_from_endpoint_type("llm")
assert capability.family == mc.FAMILY_CHAT
assert capability.primary_task == mc.TASK_CHAT_COMPLETIONS
assert capability.modalities.input == (mc.MODALITY_TEXT,)
assert capability.modalities.output == (mc.MODALITY_TEXT,)
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
assert capability.confidence == mc.CONFIDENCE_EXPLICIT
assert surfaces(capability) == {"chat"}
def test_endpoint_type_image_maps_to_explicit_image_generation_capability():
capability = mc.capability_from_endpoint_type("image")
assert capability.family == mc.FAMILY_IMAGE
assert capability.primary_task == mc.TASK_IMAGE_GENERATE
assert capability.modalities.output == (mc.MODALITY_IMAGE,)
assert capability.capabilities == (mc.CAP_IMAGE_GENERATION,)
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
assert capability.confidence == mc.CONFIDENCE_EXPLICIT
assert surfaces(capability) == {"image_generation"}
def test_missing_or_unknown_endpoint_type_does_not_imply_chat():
for model_type in (None, "", "openai-compatible", "text"):
capability = mc.capability_from_endpoint_type(model_type)
assert capability.family == mc.FAMILY_UNKNOWN
assert capability.primary_task == mc.TASK_UNKNOWN
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
assert mc.display_surfaces_for(capability) == ()
def test_provider_record_normalizes_aliases_and_boolean_capability_maps():
capability = mc.ModelCapability.from_dict(
{
"family": "llm",
"modalities": {
"input": ["text", "images", "docs", "images"],
"output": "text",
},
"capabilities": {
"tools": True,
"unknown_vendor_flag": True,
"vision": True,
"tts": False,
},
"limits": {"max_context_tokens": 32768, "": "ignored"},
"source": "provider_reader",
"confidence": "provider_reported",
}
)
assert capability.family == mc.FAMILY_CHAT
assert capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE)
assert capability.modalities.output == (mc.MODALITY_TEXT,)
assert capability.capabilities == (mc.CAP_TOOL_CALL, mc.CAP_VISION)
assert capability.limits == (("max_context_tokens", 32768),)
assert surfaces(capability) == {"chat", "vision_chat", "document_chat"}
assert capability.to_dict() == {
"family": mc.FAMILY_CHAT,
"primary_task": mc.TASK_CHAT_COMPLETIONS,
"modalities": {
"input": [mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE],
"output": [mc.MODALITY_TEXT],
},
"capabilities": [mc.CAP_TOOL_CALL, mc.CAP_VISION],
"limits": {"max_context_tokens": 32768},
"source": mc.SOURCE_PROVIDER_READER,
"confidence": mc.CONFIDENCE_PROVIDER_REPORTED,
}
def test_unknown_or_malformed_capability_record_stays_unknown():
assert mc.ModelCapability.from_dict(None).to_dict() == mc.unknown_capability().to_dict()
capability = mc.ModelCapability.build(
family="not-real",
primary_task=1234,
input_modalities=object(),
output_modalities=["text", "not-real"],
capabilities=["vision", "not-real"],
source="not-real",
confidence="not-real",
)
assert capability.family == mc.FAMILY_UNKNOWN
assert capability.primary_task == "1234"
assert capability.modalities.input == ()
assert capability.modalities.output == (mc.MODALITY_TEXT,)
assert capability.capabilities == (mc.CAP_VISION,)
assert capability.source == mc.SOURCE_UNKNOWN
assert capability.confidence == mc.CONFIDENCE_UNKNOWN
assert mc.display_surfaces_for(capability) == ()
def test_display_surface_queries_cover_core_model_categories():
assert surfaces(
mc.ModelCapability.build(
family=mc.FAMILY_IMAGE,
input_modalities=[mc.MODALITY_IMAGE],
output_modalities=[mc.MODALITY_IMAGE],
capabilities=[mc.CAP_INPAINTING],
)
) == {"image_editing"}
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_EMBEDDING)) == {"embeddings"}
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_RERANK)) == {"rerank_scoring"}
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_MODERATION)) == {"moderation_classification"}
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_CLASSIFICATION)) == {"moderation_classification"}
def test_audio_surface_matches_audio_input_or_output_when_capability_is_known():
transcription = mc.ModelCapability.build(
family=mc.FAMILY_AUDIO,
primary_task=mc.TASK_AUDIO_TRANSCRIBE,
input_modalities=[mc.MODALITY_AUDIO],
output_modalities=[mc.MODALITY_TEXT],
capabilities=[mc.CAP_TRANSCRIPTION],
)
synthesis = mc.ModelCapability.build(
family=mc.FAMILY_AUDIO,
primary_task=mc.TASK_AUDIO_SYNTHESIZE,
input_modalities=[mc.MODALITY_TEXT],
output_modalities=[mc.MODALITY_AUDIO],
capabilities=[mc.CAP_TTS],
)
assert surfaces(transcription) == {"audio_realtime"}
assert surfaces(synthesis) == {"audio_realtime"}
def test_capability_assertion_tracks_claimed_status_separately_from_capability_metadata():
assertion = mc.CapabilityAssertion.build(
capability="tools",
status="claimed",
source="provider_reader",
confidence="provider_reported",
evidence={"field": "supported_parameters"},
)
assert assertion.capability == mc.CAP_TOOL_CALL
assert assertion.status == mc.ASSERTION_CLAIMED
assert assertion.source == mc.SOURCE_PROVIDER_READER
assert assertion.confidence == mc.CONFIDENCE_PROVIDER_REPORTED
assert assertion.to_dict() == {
"capability": mc.CAP_TOOL_CALL,
"status": mc.ASSERTION_CLAIMED,
"source": mc.SOURCE_PROVIDER_READER,
"confidence": mc.CONFIDENCE_PROVIDER_REPORTED,
"evidence": {"field": "supported_parameters"},
"tested_at": "",
}
def test_capability_probe_result_converts_pass_and_fail_to_assertions():
passed = mc.CapabilityProbeResult.build(
provider="openrouter",
endpoint_id="ep-1",
model_id="vendor/model",
stable_model_id="openrouter|endpoint:ep-1|vendor/model",
capability="tool_calls",
status="pass",
tested_at="2026-06-04T20:00:00Z",
request_hash="abc123",
response_fingerprint="fp-test",
evidence={"contract": "single_fake_tool"},
)
failed = mc.CapabilityProbeResult.build(
provider="openrouter",
model_id="vendor/model",
capability="vision",
status="fail",
)
pass_assertion = passed.to_assertion()
fail_assertion = failed.to_assertion()
assert pass_assertion.capability == mc.CAP_TOOL_CALL
assert pass_assertion.status == mc.ASSERTION_VERIFIED
assert pass_assertion.source == mc.SOURCE_CAPABILITY_PROBE
assert pass_assertion.confidence == mc.CONFIDENCE_EXPLICIT
assert pass_assertion.tested_at == "2026-06-04T20:00:00Z"
assert dict(pass_assertion.evidence)["request_hash"] == "abc123"
assert dict(pass_assertion.evidence)["contract"] == "single_fake_tool"
assert fail_assertion.capability == mc.CAP_VISION
assert fail_assertion.status == mc.ASSERTION_UNSUPPORTED
assert fail_assertion.source == mc.SOURCE_CAPABILITY_PROBE
def test_deterministic_controls_are_normalized_as_claims_not_capabilities():
controls = mc.deterministic_controls_from_values(
["temp", "top-p", "top-k", "seed", "unknown"],
source=mc.SOURCE_PROVIDER_READER,
)
assert [control.control for control in controls] == [
mc.CONTROL_TEMPERATURE,
mc.CONTROL_TOP_P,
mc.CONTROL_TOP_K,
mc.CONTROL_SEED,
]
assert {control.status for control in controls} == {mc.ASSERTION_CLAIMED}
assert {control.source for control in controls} == {mc.SOURCE_PROVIDER_READER}
def test_reasoning_control_mechanisms_normalize_known_provider_shapes():
values = [
"think_directive",
"system_prompt_directive",
"enable_thinking",
"think_bool",
"reasoning_object",
"thinking_budget",
"reasoning_effort",
]
assert [mc.normalize_reasoning_control_mechanism(value) for value in values] == [
mc.REASONING_CONTROL_MESSAGE_DIRECTIVE,
mc.REASONING_CONTROL_SYSTEM_DIRECTIVE,
mc.REASONING_CONTROL_TEMPLATE_KWARG,
mc.REASONING_CONTROL_NATIVE_BOOL,
mc.REASONING_CONTROL_STRUCTURED_OBJECT,
mc.REASONING_CONTROL_BUDGET,
mc.REASONING_CONTROL_EFFORT,
]
def test_reasoning_control_values_can_describe_provider_supported_auto():
values = ["enabled", "disabled", "adaptive", "dynamic", "provider_auto"]
assert [mc.normalize_reasoning_control_value(value) for value in values] == [
mc.REASONING_CONTROL_VALUE_ON,
mc.REASONING_CONTROL_VALUE_OFF,
mc.REASONING_CONTROL_VALUE_AUTO,
mc.REASONING_CONTROL_VALUE_AUTO,
mc.REASONING_CONTROL_VALUE_AUTO,
]
assert mc.normalize_reasoning_control_value("message_directive") == ""
+646
View File
@@ -0,0 +1,646 @@
import src.model_capabilities as mc
import src.model_capability_readers as readers
from src.model_capability_readers import generic_openai, google, llamacpp, lmstudio, ollama, openai, openrouter
from src.model_capability_readers.base import (
VENDOR_GENERIC_OPENAI,
VENDOR_GOOGLE,
VENDOR_LLAMACPP,
VENDOR_LMSTUDIO,
VENDOR_OLLAMA,
VENDOR_OPENAI,
VENDOR_OPENROUTER,
detect_vendor,
stable_model_id_for,
)
def surfaces(record):
return set(mc.display_surfaces_for(record.capability))
def test_detect_vendor_uses_endpoint_kind_then_host_and_common_local_ports():
assert detect_vendor("https://example.test/v1", endpoint_kind="ollama") == VENDOR_OLLAMA
assert detect_vendor("http://127.0.0.1:8080", endpoint_kind="llama_cpp") == VENDOR_LLAMACPP
assert detect_vendor("https://openrouter.ai/api/v1") == VENDOR_OPENROUTER
assert detect_vendor("https://api.openai.com/v1") == VENDOR_OPENAI
assert detect_vendor("https://generativelanguage.googleapis.com/v1beta/openai") == VENDOR_GOOGLE
assert detect_vendor("http://127.0.0.1:11434") == VENDOR_OLLAMA
assert detect_vendor("http://127.0.0.1:1234") == VENDOR_LMSTUDIO
assert detect_vendor("http://127.0.0.1:8080") == VENDOR_GENERIC_OPENAI
assert detect_vendor("http://localhost:7000/v1") == VENDOR_GENERIC_OPENAI
def test_generic_openai_reader_keeps_basic_model_payload_unknown():
records = generic_openai.records_from_payload(
{
"object": "list",
"data": [
{"id": "gpt-example", "object": "model", "owned_by": "vendor"},
],
}
)
assert len(records) == 1
record = records[0]
assert record.vendor == VENDOR_GENERIC_OPENAI
assert record.model_id == "gpt-example"
assert record.capability.family == mc.FAMILY_UNKNOWN
assert record.capability.source == mc.SOURCE_PROVIDER_READER
assert record.capability.confidence == mc.CONFIDENCE_UNKNOWN
assert record.stable_model_id == "generic_openai|global|gpt-example"
assert record.capability_assertions == ()
assert record.deterministic_controls == ()
assert surfaces(record) == set()
def test_stable_model_id_is_endpoint_scoped_for_local_or_configured_servers():
assert stable_model_id_for("ollama", "qwen:latest", endpoint_id="7") == "ollama|endpoint:7|qwen:latest"
assert stable_model_id_for("ollama", "qwen:latest", base_url="http://127.0.0.1:11434") != stable_model_id_for(
"ollama",
"qwen:latest",
base_url="http://10.0.0.12:11434",
)
def test_registry_uses_openai_reader_for_openai_vendor():
records = readers.records_from_payload({"data": [{"id": "shape-only-model"}]}, vendor=VENDOR_OPENAI)
assert len(records) == 1
assert records[0].vendor == VENDOR_OPENAI
assert records[0].stable_model_id == "openai|global|shape-only-model"
assert records[0].capability.family == mc.FAMILY_UNKNOWN
def test_openai_reader_keeps_official_model_shape_identity_only():
records = openai.records_from_payload(
{
"object": "list",
"data": [
{
"id": "shape-only-model",
"object": "model",
"created": 1700000000,
"owned_by": "openai",
}
],
}
)
assert len(records) == 1
record = records[0]
assert record.vendor == VENDOR_OPENAI
assert record.model_id == "shape-only-model"
assert record.capability.family == mc.FAMILY_UNKNOWN
assert record.capability.source == mc.SOURCE_PROVIDER_READER
assert record.capability.confidence == mc.CONFIDENCE_UNKNOWN
assert record.capability_assertions == ()
assert record.deterministic_controls == ()
assert surfaces(record) == set()
def test_registry_passes_endpoint_context_to_vendor_reader():
records = readers.records_from_payload(
{"data": [{"id": "local.gguf", "owned_by": "llamacpp"}]},
vendor=VENDOR_LLAMACPP,
base_url="http://localhost:8000",
)
assert len(records) == 1
assert records[0].stable_model_id == stable_model_id_for(
VENDOR_LLAMACPP,
"local.gguf",
base_url="http://localhost:8000",
)
def test_openrouter_reader_maps_rich_architecture_and_supported_parameters():
records = openrouter.records_from_payload(
{
"data": [
{
"id": "google/gemini-vision",
"name": "Gemini Vision",
"architecture": {"modality": "text+image->text"},
"supported_parameters": [
"tools",
"response_format",
"reasoning",
"include_reasoning",
"parallel_tool_calls",
"temperature",
"top_p",
"seed",
],
"context_length": 1048576,
"top_provider": {"max_completion_tokens": 65536},
},
{
"id": "black-forest-labs/flux",
"architecture": {"input_modalities": ["text"], "output_modalities": ["image"]},
},
{
"id": "vendor/image-edit-shape",
"architecture": {"input_modalities": ["text", "image", "file"], "output_modalities": ["text", "image"]},
"supported_parameters": ["structured_outputs", "web_search_options"],
},
{
"id": "vendor/audio-shape",
"architecture": {"input_modalities": ["text", "audio"], "output_modalities": ["text", "audio"]},
"supported_voices": ["alloy"],
"default_parameters": {"temperature": 0.7, "top_p": 0.9, "top_k": None},
"per_request_limits": {"prompt_tokens": 12000, "completion_tokens": 4000, "requests": "2"},
},
{
"id": "vendor/embedder",
"architecture": {"modality": "text->embedding"},
},
]
}
)
assert [record.model_id for record in records] == [
"google/gemini-vision",
"black-forest-labs/flux",
"vendor/image-edit-shape",
"vendor/audio-shape",
"vendor/embedder",
]
vision = records[0]
assert vision.capability.family == mc.FAMILY_CHAT
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
assert vision.capability.capabilities == (
mc.CAP_TOOL_CALL,
mc.CAP_JSON_MODE,
mc.CAP_REASONING,
mc.CAP_VISION,
)
assert [(assertion.capability, assertion.status) for assertion in vision.capability_assertions] == [
(mc.CAP_TOOL_CALL, mc.ASSERTION_CLAIMED),
(mc.CAP_JSON_MODE, mc.ASSERTION_CLAIMED),
(mc.CAP_REASONING, mc.ASSERTION_CLAIMED),
(mc.CAP_VISION, mc.ASSERTION_CLAIMED),
]
assert [(control.control, control.status) for control in vision.deterministic_controls] == [
(mc.CONTROL_TEMPERATURE, mc.ASSERTION_CLAIMED),
(mc.CONTROL_TOP_P, mc.ASSERTION_CLAIMED),
(mc.CONTROL_SEED, mc.ASSERTION_CLAIMED),
]
assert dict(vision.capability.limits) == {"context_tokens": 1048576, "output_tokens": 65536}
assert surfaces(vision) == {"chat", "vision_chat"}
assert records[1].capability.family == mc.FAMILY_IMAGE
assert records[1].capability.capabilities == (mc.CAP_IMAGE_GENERATION,)
assert surfaces(records[1]) == {"image_generation"}
image_edit = records[2]
assert image_edit.capability.family == mc.FAMILY_IMAGE
assert image_edit.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE)
assert image_edit.capability.modalities.output == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
assert image_edit.capability.capabilities == (
mc.CAP_STRUCTURED_OUTPUT,
mc.CAP_WEB_SEARCH,
mc.CAP_VISION,
mc.CAP_FILES,
mc.CAP_IMAGE_GENERATION,
mc.CAP_IMAGE_EDITING,
)
assert surfaces(image_edit) == {"image_generation", "image_editing"}
audio = records[3]
assert audio.capability.family == mc.FAMILY_AUDIO
assert audio.capability.capabilities == (mc.CAP_AUDIO_INPUT, mc.CAP_AUDIO_OUTPUT, mc.CAP_TTS)
assert dict(audio.capability.limits) == {
"per_request_completion_tokens": 4000,
"per_request_prompt_tokens": 12000,
"per_request_requests": 2,
}
assert [control.control for control in audio.deterministic_controls] == [
mc.CONTROL_TEMPERATURE,
mc.CONTROL_TOP_P,
]
assert surfaces(audio) == {"audio_realtime"}
assert records[4].capability.family == mc.FAMILY_EMBEDDING
assert surfaces(records[4]) == {"embeddings"}
def test_google_reader_maps_provider_fields_without_claiming_unreported_modalities():
records = google.records_from_payload(
{
"models": [
{
"name": "models/gemini-3.1-flash-image",
"displayName": "Gemini 3.1 Flash Image",
"supportedGenerationMethods": ["generateContent"],
"inputTokenLimit": 1000000,
"outputTokenLimit": 8192,
"thinking": True,
"temperature": 1.0,
"topP": 0.95,
"topK": 40,
},
{
"name": "models/text-embedding-example",
"supportedGenerationMethods": ["embedContent"],
},
]
}
)
assert len(records) == 2
content = records[0]
assert content.vendor == VENDOR_GOOGLE
assert content.model_id == "gemini-3.1-flash-image"
assert content.capability.family == mc.FAMILY_UNKNOWN
assert content.capability.modalities.input == ()
assert content.capability.modalities.output == ()
assert content.capability.capabilities == (mc.CAP_REASONING,)
assert dict(content.capability.limits) == {
"context_tokens": 1000000,
"input_tokens": 1000000,
"output_tokens": 8192,
}
assert [control.control for control in content.deterministic_controls] == [
mc.CONTROL_TEMPERATURE,
mc.CONTROL_TOP_P,
mc.CONTROL_TOP_K,
]
assert surfaces(content) == set()
embedding = records[1]
assert embedding.capability.family == mc.FAMILY_EMBEDDING
assert surfaces(embedding) == {"embeddings"}
def test_google_ai_studio_mapping_does_not_infer_media_from_model_names():
records = google.records_from_payload(
{
"models": [
{
"name": "models/imagen-4.0-generate-001",
"displayName": "Imagen 4",
"supportedGenerationMethods": ["predict"],
},
{
"name": "models/veo-3.1-generate-preview",
"displayName": "Veo 3.1",
"supportedGenerationMethods": ["predictLongRunning"],
},
{
"name": "models/gemini-3.1-flash-tts-preview",
"supportedGenerationMethods": ["generateContent", "countTokens", "createCachedContent", "batchGenerateContent"],
},
{
"name": "models/lyria-3-pro-preview",
"displayName": "Lyria 3 Pro Preview",
"supportedGenerationMethods": ["generateContent", "countTokens"],
},
]
}
)
assert len(records) == 4
assert [record.capability.family for record in records] == [
mc.FAMILY_UNKNOWN,
mc.FAMILY_UNKNOWN,
mc.FAMILY_UNKNOWN,
mc.FAMILY_UNKNOWN,
]
assert all(record.capability.modalities.input == () for record in records)
assert all(record.capability.modalities.output == () for record in records)
assert all(surfaces(record) == set() for record in records)
assert [control.control for control in records[2].deterministic_controls] == [
mc.CONTROL_PROMPT_CACHING,
mc.CONTROL_BATCH,
]
def test_google_ai_studio_mapping_keeps_unrecognized_predict_models_unknown():
records = google.records_from_payload(
{
"models": [
{
"name": "models/vendor-future-media-001",
"supportedGenerationMethods": ["predict"],
}
]
}
)
assert len(records) == 1
assert records[0].capability.family == mc.FAMILY_UNKNOWN
assert surfaces(records[0]) == set()
def test_ollama_reader_maps_show_capabilities_and_tags_are_unknown():
vision = ollama.record_from_show_payload(
"llava:latest",
{
"capabilities": ["completion", "vision", "tools"],
"model_info": {"llama.context_length": 4096},
},
)
embedding = ollama.record_from_show_payload(
"nomic-embed-text:latest",
{"capabilities": ["embedding"]},
)
tags = ollama.records_from_tags_payload({"models": [{"name": "qwen3:latest"}]})
assert vision is not None
assert vision.capability.family == mc.FAMILY_CHAT
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
assert vision.capability.capabilities == (mc.CAP_VISION, mc.CAP_TOOL_CALL)
assert dict(vision.capability.limits) == {"context_tokens": 4096}
assert surfaces(vision) == {"chat", "vision_chat"}
assert embedding is not None
assert embedding.capability.family == mc.FAMILY_EMBEDDING
assert surfaces(embedding) == {"embeddings"}
assert len(tags) == 1
assert tags[0].capability.family == mc.FAMILY_UNKNOWN
assert surfaces(tags[0]) == set()
def test_ollama_reader_uses_show_shape_without_architecture_name_matching():
record = ollama.record_from_show_payload(
"local:latest",
{
"capabilities": ["completion", "thinking", "tools"],
"parameters": "temperature 0.7\nnum_ctx 8192",
"model_info": {
"future_architecture.context_length": 32768,
"future_architecture.embedding_length": 4096,
},
},
)
assert record is not None
assert record.capability.family == mc.FAMILY_CHAT
assert record.capability.modalities.input == (mc.MODALITY_TEXT,)
assert record.capability.modalities.output == (mc.MODALITY_TEXT,)
assert record.capability.capabilities == (mc.CAP_REASONING, mc.CAP_TOOL_CALL)
assert dict(record.capability.limits) == {"context_tokens": 8192}
assert surfaces(record) == {"chat"}
def test_ollama_reader_uses_generic_model_info_context_length_when_no_num_ctx():
record = ollama.record_from_show_payload(
"local:latest",
{
"capabilities": ["completion"],
"model_info": {"future_architecture.context_length": 32768},
},
)
assert record is not None
assert record.capability.family == mc.FAMILY_CHAT
assert dict(record.capability.limits) == {"context_tokens": 32768}
def test_lmstudio_reader_uses_native_v1_capabilities_when_present():
records = lmstudio.records_from_payload(
{
"models": [
{
"type": "llm",
"key": "google/gemma-vl",
"display_name": "Gemma VL",
"capabilities": {
"vision": True,
"trained_for_tool_use": True,
"reasoning": {"allowed_options": ["off", "on"], "default": "on"},
},
"loaded_instances": [
{"config": {"context_length": 8192}},
{"config": {"context_length": 4096}},
],
"max_context_length": 262144,
},
{
"type": "embedding",
"key": "nomic/embed",
},
{"key": "shape-without-type"},
]
}
)
assert len(records) == 3
vision = records[0]
assert vision.vendor == VENDOR_LMSTUDIO
assert vision.model_id == "google/gemma-vl"
assert vision.display_name == "Gemma VL"
assert vision.capability.family == mc.FAMILY_CHAT
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
assert vision.capability.capabilities == (mc.CAP_VISION, mc.CAP_TOOL_CALL, mc.CAP_REASONING)
assert dict(vision.capability.limits) == {"context_tokens": 4096, "max_context_tokens": 262144}
assert surfaces(vision) == {"chat", "vision_chat"}
assert records[1].capability.family == mc.FAMILY_EMBEDDING
assert surfaces(records[1]) == {"embeddings"}
assert records[2].capability.family == mc.FAMILY_UNKNOWN
assert surfaces(records[2]) == set()
def test_lmstudio_reader_uses_legacy_native_v0_shape_for_family_and_limits():
records = lmstudio.records_from_payload(
{
"data": [
{
"id": "local-gemma",
"type": "llm",
"arch": "gemma3",
"loaded_context_length": 16384,
"max_context_length": 32768,
},
{
"id": "text-embedding-local",
"type": "embeddings",
"max_context_length": 2048,
},
]
}
)
assert len(records) == 2
chat = records[0]
assert chat.vendor == VENDOR_LMSTUDIO
assert chat.capability.family == mc.FAMILY_CHAT
assert chat.capability.modalities.input == (mc.MODALITY_TEXT,)
assert chat.capability.capabilities == ()
assert dict(chat.capability.limits) == {"context_tokens": 16384, "max_context_tokens": 32768}
assert surfaces(chat) == {"chat"}
assert records[1].capability.family == mc.FAMILY_EMBEDDING
assert dict(records[1].capability.limits) == {"context_tokens": 2048}
assert surfaces(records[1]) == {"embeddings"}
def test_lmstudio_openai_compatible_model_list_remains_identity_only():
records = lmstudio.records_from_payload(
{
"object": "list",
"data": [
{"id": "local-gemma-3-270m-it-qat-q4_k_m", "object": "model", "owned_by": "organization_owner"},
{"id": "text-embedding-nomic-embed-text-v1.5", "object": "model", "owned_by": "organization_owner"},
],
}
)
assert len(records) == 2
for record in records:
assert record.vendor == VENDOR_LMSTUDIO
assert record.capability.family == mc.FAMILY_UNKNOWN
assert record.capability_assertions == ()
assert surfaces(record) == set()
def test_lmstudio_unexpected_native_endpoint_error_yields_no_records():
assert lmstudio.records_from_payload({"error": "Unexpected endpoint or method. (GET /api/v1/models)"}) == ()
def test_llamacpp_reader_merges_models_props_and_slots_payloads():
models_payload = {
"object": "list",
"data": [
{
"id": "gemma-4-E2B-it-Q8_0.gguf",
"owned_by": "llamacpp",
"meta": {
"n_ctx_train": 131072,
"n_params": 4647450147,
"size": 5032532108,
},
}
],
"models": [
{
"name": "gemma-4-E2B-it-Q8_0.gguf",
"model": "gemma-4-E2B-it-Q8_0.gguf",
"capabilities": ["completion"],
"details": {"format": "gguf"},
}
],
}
props_payload = {
"model_alias": "gemma-4-E2B-it-Q8_0.gguf",
"model_path": "/models/gemma-4-E2B-it-Q8_0.gguf",
"build_info": "b1-c8ac02f",
"total_slots": 4,
"modalities": {"vision": False, "audio": False},
"chat_template_caps": {
"supports_object_arguments": True,
"supports_parallel_tool_calls": True,
"supports_preserve_reasoning": False,
"supports_string_content": True,
"supports_system_role": True,
"supports_tool_calls": True,
"supports_tools": True,
"supports_typed_content": False,
},
"default_generation_settings": {
"n_ctx": 16384,
"params": {
"temperature": 1.0,
"top_p": 0.95,
"seed": 4294967295,
"stream": True,
"samplers": ["top_p", "temperature"],
},
},
}
slots_payload = [
{"id": 0, "n_ctx": 16384, "speculative": False, "is_processing": False},
{"id": 1, "n_ctx": 16384, "speculative": False, "is_processing": False},
{"id": 2, "n_ctx": 16384, "speculative": False, "is_processing": False},
{"id": 3, "n_ctx": 16384, "speculative": False, "is_processing": False},
]
records = llamacpp.records_from_payloads(
models_payload=models_payload,
props_payload=props_payload,
slots_payload=slots_payload,
base_url="http://localhost:8000",
)
assert len(records) == 1
record = records[0]
assert record.vendor == VENDOR_LLAMACPP
assert record.model_id == "gemma-4-E2B-it-Q8_0.gguf"
assert record.stable_model_id == stable_model_id_for(
VENDOR_LLAMACPP,
"gemma-4-E2B-it-Q8_0.gguf",
base_url="http://localhost:8000",
)
assert record.capability.family == mc.FAMILY_CHAT
assert record.capability.modalities.input == (mc.MODALITY_TEXT,)
assert record.capability.modalities.output == (mc.MODALITY_TEXT,)
assert record.capability.capabilities == (mc.CAP_TOOL_CALL, mc.CAP_STREAMING)
assert dict(record.capability.limits) == {
"context_tokens": 16384,
"model_bytes": 5032532108,
"parallel_slots": 4,
"parameters": 4647450147,
"training_context_tokens": 131072,
}
assert surfaces(record) == {"chat"}
assertion_status = {assertion.capability: assertion.status for assertion in record.capability_assertions}
assert assertion_status[mc.CAP_TOOL_CALL] == mc.ASSERTION_CLAIMED
assert assertion_status[mc.CAP_STREAMING] == mc.ASSERTION_CLAIMED
assert assertion_status[mc.CAP_VISION] == mc.ASSERTION_UNSUPPORTED
assert assertion_status[mc.CAP_AUDIO_INPUT] == mc.ASSERTION_UNSUPPORTED
assert mc.CAP_REASONING not in assertion_status
controls = {control.control: control.status for control in record.deterministic_controls}
assert controls == {
mc.CONTROL_TEMPERATURE: mc.ASSERTION_CLAIMED,
mc.CONTROL_TOP_P: mc.ASSERTION_CLAIMED,
mc.CONTROL_SEED: mc.ASSERTION_CLAIMED,
mc.CONTROL_SYSTEM_PROMPT: mc.ASSERTION_CLAIMED,
mc.CONTROL_TOOL_CHOICE: mc.ASSERTION_CLAIMED,
}
def test_llamacpp_openai_model_list_without_native_capability_shape_stays_unknown():
records = llamacpp.records_from_payload(
{
"object": "list",
"data": [
{
"id": "local-chat.gguf",
"owned_by": "llamacpp",
}
],
}
)
assert len(records) == 1
assert records[0].capability.family == mc.FAMILY_UNKNOWN
assert records[0].capability.capabilities == ()
assert records[0].capability_assertions == ()
assert surfaces(records[0]) == set()
def test_llamacpp_props_payload_reports_unsupported_modalities_without_model_list():
records = llamacpp.records_from_payload(
{
"model_alias": "local.gguf",
"modalities": {"vision": False, "audio": False},
"chat_template_caps": {"supports_tools": False, "supports_preserve_reasoning": False},
"default_generation_settings": {"n_ctx": 4096, "params": {"stream": True}},
}
)
assert len(records) == 1
record = records[0]
assert record.capability.family == mc.FAMILY_CHAT
assert record.capability.capabilities == (mc.CAP_STREAMING,)
assert {a.capability: a.status for a in record.capability_assertions} == {
mc.CAP_STREAMING: mc.ASSERTION_CLAIMED,
mc.CAP_VISION: mc.ASSERTION_UNSUPPORTED,
mc.CAP_AUDIO_INPUT: mc.ASSERTION_UNSUPPORTED,
}
+1 -1
View File
@@ -40,6 +40,6 @@ def test_history_compact_resolves_with_owner_scope():
def test_note_reminder_synthesis_resolves_with_owner_scope():
body = _function_source("routes/note_routes.py", "dispatch_reminder")
body = _function_source("routes/note/note_routes.py", "dispatch_reminder")
assert 'resolve_endpoint("utility", owner=owner or None)' in body
assert 'resolve_endpoint("default", owner=owner or None)' in body
+18
View File
@@ -24,3 +24,21 @@ def test_header_indicator_has_title_tooltip():
body = SRC[SRC.index("export function updateModelPicker()"):]
assert re.search(r"label\.title\s*=\s*modelId\b", body), \
"header model indicator needs a title tooltip (#1982)"
def test_api_picker_dedupe_includes_endpoint_id():
# API providers can expose the same model id intentionally. The chat picker
# must not dedupe OpenRouter away just because OpenAI has the same id.
assert "const isApiEndpoint = item.category && item.category !== 'local';" in SRC
assert re.search(r"const seenKey = isApiEndpoint\s*\?", SRC), \
"chat picker should dedupe API models by endpoint+model, not model id only"
assert "${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}" in SRC
def test_api_picker_groups_by_endpoint_name():
# OpenRouter models often have ids like openai/* or google/*; browse mode
# should still show them under the OpenRouter endpoint group.
assert "function _providerGroupKey(m)" in SRC
assert "m.category && m.category !== 'local' && m.epName" in SRC
assert "`~endpoint:${m.epName}`" in SRC
assert "_providerGroupName(provider)" in SRC
+310 -3
View File
@@ -48,6 +48,9 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
_ping_endpoint,
_parse_model_list,
_normalize_refresh_mode,
_normalize_endpoint_refresh_mode,
_endpoint_refresh_mode,
_is_google_api_base,
_truthy,
_speech_settings_using_endpoint,
_clear_speech_settings_for_endpoint,
@@ -436,6 +439,9 @@ class TestClassifyEndpoint:
def test_public_api(self):
assert _classify_endpoint("https://api.openai.com/v1") == "api"
def test_openrouter_api(self):
assert _classify_endpoint("https://openrouter.ai/api/v1") == "api"
def test_empty_string(self):
assert _classify_endpoint("") == "api"
@@ -464,6 +470,28 @@ class TestClassifyEndpoint:
assert _normalize_refresh_mode("manual", "proxy") == "manual"
assert _normalize_refresh_mode("auto", "api") == "auto"
def test_google_refresh_mode_defaults_manual_unless_explicit(self):
base = "https://generativelanguage.googleapis.com/v1beta/openai"
assert _normalize_endpoint_refresh_mode("", "api", base) == "manual"
assert _normalize_endpoint_refresh_mode(None, "auto", base) == "manual"
assert _normalize_endpoint_refresh_mode("auto", "api", base) == "auto"
def test_only_gemini_native_host_uses_google_models_api(self):
assert _is_google_api_base("https://generativelanguage.googleapis.com/v1beta/openai") is True
assert _is_google_api_base(
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/endpoints/openapi"
) is False
def test_existing_google_endpoint_refresh_mode_defaults_manual(self):
ep = SimpleNamespace(
model_refresh_mode=None,
endpoint_kind="api",
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
)
assert _endpoint_refresh_mode(ep, "api") == "manual"
ep.model_refresh_mode = "auto"
assert _endpoint_refresh_mode(ep, "api") == "auto"
def test_parse_model_list_accepts_json_and_text(self):
assert _parse_model_list('["a", "b", "a"]') == ["a", "b"]
assert _parse_model_list("a, b\nc") == ["a", "b", "c"]
@@ -568,6 +596,83 @@ class TestSetupProbeSafety:
assert _probe_endpoint("https://api.groq.com/openai/v1") == _PROVIDER_CURATED["groq"]
def test_google_probe_uses_native_paginated_models_api(self, monkeypatch):
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
seen = []
def fake_get(url, headers=None, params=None, timeout=None, verify=None, **kwargs):
seen.append((url, headers, params, timeout, verify))
request = httpx.Request("GET", url)
page_token = (params or {}).get("pageToken")
if page_token:
return httpx.Response(
200,
request=request,
json={
"models": [{
"name": "models/gemini-page-two",
"supportedGenerationMethods": ["generateContent"],
}]
},
)
return httpx.Response(
200,
request=request,
json={
"models": [
{
"name": "models/gemini-page-one",
"supportedGenerationMethods": ["generateContent"],
},
{
"baseModelId": "gemini-base-id",
"name": "models/ignored-version",
"supportedGenerationMethods": ["generateText"],
},
{
"name": "models/imagen-4.0-generate-001",
"supportedGenerationMethods": ["predict"],
},
{
"name": "models/text-embedding-example",
"supportedGenerationMethods": ["embedContent"],
},
{"name": "models/missing-method-metadata"},
],
"nextPageToken": "next-page",
},
)
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _probe_endpoint("https://generativelanguage.googleapis.com/v1beta/openai", "google-key") == [
"gemini-page-one",
"gemini-base-id",
"gemini-page-two",
]
assert [call[0] for call in seen] == [
"https://generativelanguage.googleapis.com/v1beta/models",
"https://generativelanguage.googleapis.com/v1beta/models",
]
assert seen[0][1] == {"Accept": "application/json", "x-goog-api-key": "google-key"}
assert seen[0][2] == {"pageSize": 1000}
assert seen[1][1] == {"Accept": "application/json", "x-goog-api-key": "google-key"}
assert seen[1][2] == {"pageSize": 1000, "pageToken": "next-page"}
def test_google_probe_does_not_use_curated_fallback_on_failure(self, monkeypatch):
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
def fake_get(url, headers=None, params=None, timeout=None, verify=None, **kwargs):
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _probe_endpoint("https://generativelanguage.googleapis.com/v1beta/openai", "bad-key") == []
def test_keyed_anthropic_probe_does_not_fallback_on_failure(self, monkeypatch):
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
@@ -908,6 +1013,44 @@ def test_patch_models_pinned_does_not_clobber_hidden(monkeypatch):
assert json.loads(ep.pinned_models) == ["deploy-1"]
def test_patch_api_hidden_payload_converts_to_pinned(monkeypatch):
ep = _make_endpoint(
base_url="https://openrouter.ai/api/v1",
cached_models=json.dumps(["m1", "m2", "m3"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": ["m2"]})))
assert result["pinned_count"] == 2
assert result["hidden_count"] == 0
assert json.loads(ep.pinned_models) == ["m1", "m3"]
assert ep.hidden_models is None
def test_patch_api_hidden_empty_pins_all_cached_models(monkeypatch):
ep = _make_endpoint(
base_url="https://openrouter.ai/api/v1",
cached_models=json.dumps(["m1", "m2", "m3"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": []})))
assert result["pinned_count"] == 3
assert result["hidden_count"] == 0
assert json.loads(ep.pinned_models) == ["m1", "m2", "m3"]
assert ep.hidden_models is None
def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
db = _PinnedFakeDb([ep])
@@ -923,6 +1066,26 @@ def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
assert result[0]["is_pinned"] is True
def test_get_api_models_marks_picker_as_pinned_only(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
pinned_models=json.dumps(["openai/gpt-image-1"]),
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
by_id = {row["id"]: row for row in result}
assert by_id["openai/gpt-image-1"]["picker_requires_pinning"] is True
assert by_id["openai/gpt-image-1"]["is_pinned"] is True
assert by_id["anthropic/claude-sonnet-4"]["picker_requires_pinning"] is True
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
def test_reprobe_preserves_pinned_models(monkeypatch):
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
db = _PinnedFakeDb([ep])
@@ -1082,6 +1245,79 @@ def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
assert result[1]["api_key_fingerprint"] == ""
def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints", "GET")
result = endpoint(_PinnedFakeRequest())
assert result[0]["models"] == []
assert result[0]["model_count"] == 2
assert result[0]["picker_requires_pinning"] is True
assert result[0]["status"] == "online"
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
pinned_models=json.dumps(["openai/gpt-image-1"]),
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints", "GET")
result = endpoint(_PinnedFakeRequest())
assert result[0]["models"] == ["openai/gpt-image-1"]
assert result[0]["pinned_models"] == ["openai/gpt-image-1"]
assert result[0]["model_count"] == 2
def test_list_api_endpoint_pinned_models_ignore_stale_hidden_state(monkeypatch):
ep = _make_endpoint(
base_url="https://api.example.test/v1",
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
hidden_models=json.dumps(["openai/gpt-image-1"]),
pinned_models=json.dumps(["openai/gpt-image-1"]),
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints", "GET")
result = endpoint(_PinnedFakeRequest())
assert result[0]["models"] == ["openai/gpt-image-1"]
def test_list_api_endpoint_derives_pins_from_legacy_hidden_state(monkeypatch):
ep = _make_endpoint(
base_url="https://openrouter.ai/api/v1",
cached_models=json.dumps(["m1", "m2", "m3"]),
hidden_models=json.dumps(["m2"]),
pinned_models=None,
)
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints", "GET")
result = endpoint(_PinnedFakeRequest())
assert result[0]["models"] == ["m1", "m3"]
assert result[0]["pinned_models"] == ["m1", "m3"]
assert json.loads(ep.pinned_models) == ["m1", "m3"]
def test_post_creates_endpoint_with_pinned_models(monkeypatch):
db = _PinnedFakeDb([]) # no existing row → fresh create path
_patch_create_deps(monkeypatch, db)
@@ -1101,6 +1337,26 @@ def test_post_creates_endpoint_with_pinned_models(monkeypatch):
assert json.loads(db.added[0].pinned_models) == ["deploy-1", "deploy-2"]
def test_post_google_endpoint_defaults_to_manual_refresh_when_mode_omitted(monkeypatch):
db = _PinnedFakeDb([])
_patch_create_deps(monkeypatch, db)
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *args, **kwargs: ["gemini-test"])
create = _get_route("/api/model-endpoints", "POST")
create(
_PinnedFakeRequest(),
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
**_create_form_kwargs(
api_key="google-key",
endpoint_kind="api",
model_refresh_mode="",
),
)
assert len(db.added) == 1
assert db.added[0].model_refresh_mode == "manual"
def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
existing = _make_endpoint(
base_url="http://host:1234/v1",
@@ -1431,11 +1687,12 @@ def test_api_models_scopes_api_token_to_token_owner(monkeypatch):
assert admin_checks == ["alice"]
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
def test_api_models_returns_only_pinned_proxy_models_without_refresh_probe(monkeypatch):
row = _route_ep(
"proxy",
"http://100.117.136.97:34521/v1",
cached_models=["cached-model"],
cached_models=["cached-model", "other-model"],
pinned_models=["cached-model"],
endpoint_kind="proxy",
api_key="fake-key",
refresh_mode="manual",
@@ -1457,10 +1714,60 @@ def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatc
result = _route_endpoint(router, "/api/models")(_route_request())
assert result["items"][0]["models"] == ["cached-model"]
assert result["items"][0]["models_extra"] == []
assert result["items"][0]["category"] == "api"
assert result["items"][0]["endpoint_kind"] == "proxy"
assert "offline" not in result["items"][0]
assert json.loads(row.cached_models) == ["cached-model"]
assert json.loads(row.cached_models) == ["cached-model", "other-model"]
def test_api_models_openrouter_uses_pinned_models_not_hidden(monkeypatch):
row = _route_ep(
"openrouter",
"https://openrouter.ai/api/v1",
cached_models=["openai/gpt-image-1", "anthropic/claude-sonnet-4"],
pinned_models=["openai/gpt-image-1"],
api_key="fake-key",
)
row.hidden_models = json.dumps(["openai/gpt-image-1"])
db = _RouteDb([row])
router = model_routes.setup_model_routes(model_discovery=None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
monkeypatch.setattr(threading, "Thread", _NoopThread)
result = _route_endpoint(router, "/api/models")(_route_request())
assert result["items"][0]["endpoint_name"] == "openrouter"
assert result["items"][0]["category"] == "api"
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
row = _route_ep(
"openrouter",
"https://openrouter.ai/api/v1",
cached_models=["m1", "m2", "m3"],
pinned_models=None,
api_key="fake-key",
)
row.hidden_models = json.dumps(["m2"])
db = _RouteDb([row])
router = model_routes.setup_model_routes(model_discovery=None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
monkeypatch.setattr(threading, "Thread", _NoopThread)
result = _route_endpoint(router, "/api/models")(_route_request())
assert result["items"][0]["endpoint_name"] == "openrouter"
assert result["items"][0]["models"] == ["m1", "m3"]
@pytest.mark.asyncio
+178
View File
@@ -0,0 +1,178 @@
import asyncio
import json
import time
from types import SimpleNamespace
import pytest
class FakeQuery:
def __init__(self, servers):
self._servers = servers
def filter(self, *_args, **_kwargs):
return self
def all(self):
return self._servers
class FakeDB:
def __init__(self, servers):
self._servers = servers
def query(self, *_args, **_kwargs):
return FakeQuery(self._servers)
def close(self):
pass
@pytest.mark.asyncio
async def test_connect_all_enabled_runs_concurrently(monkeypatch):
from src.mcp_manager import McpManager
manager = McpManager()
servers = [
SimpleNamespace(
id=1,
name="server1",
transport="stdio",
command="cmd1",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
SimpleNamespace(
id=2,
name="server2",
transport="stdio",
command="cmd2",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
SimpleNamespace(
id=3,
name="server3",
transport="stdio",
command="cmd3",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
]
# Patch the SessionLocal used by connect_all_enabled().
import src.mcp_manager as mcp_manager
monkeypatch.setattr(
mcp_manager,
"SessionLocal",
lambda: FakeDB(servers),
)
async def fake_connect_with_timeout(_server):
await asyncio.sleep(1)
# We're testing that connect_all_enabled launches these concurrently,
# not the implementation of connect_server().
monkeypatch.setattr(
manager,
"_connect_with_timeout",
fake_connect_with_timeout,
)
start = time.perf_counter()
await manager.connect_all_enabled()
elapsed = time.perf_counter() - start
# Sequential would take ~3 seconds.
# Concurrent should take about 1 second.
assert 0.9 <= elapsed < 2.0
@pytest.mark.asyncio
async def test_connect_all_enabled_timeout_does_not_block_other_servers(monkeypatch):
import src.mcp_manager as mcp_manager
from src.mcp_manager import McpManager
manager = McpManager()
servers = [
SimpleNamespace(
id=1,
name="fast1",
transport="stdio",
command="cmd1",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
SimpleNamespace(
id=2,
name="slow",
transport="stdio",
command="cmd2",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
SimpleNamespace(
id=3,
name="fast2",
transport="stdio",
command="cmd3",
args=json.dumps([]),
env=json.dumps({}),
url=None,
),
]
monkeypatch.setattr(
mcp_manager,
"SessionLocal",
lambda: FakeDB(servers),
)
completed = []
async def fake_connect_server(server_id, **kwargs):
if server_id == 2:
# Simulate a hung connection.
await asyncio.sleep(30)
else:
await asyncio.sleep(0.1)
completed.append(server_id)
monkeypatch.setattr(
manager,
"connect_server",
fake_connect_server,
)
#
# Don't actually wait 20 seconds during the test.
# Replace asyncio.wait_for used by mcp_manager with a much shorter timeout.
#
real_wait_for = asyncio.wait_for
async def short_wait_for(awaitable, timeout):
return await real_wait_for(awaitable, timeout=0.2)
monkeypatch.setattr(
mcp_manager.asyncio,
"wait_for",
short_wait_for,
)
start = time.perf_counter()
await manager.connect_all_enabled()
elapsed = time.perf_counter() - start
assert set(completed) == {1, 3}
assert elapsed < 1
+62
View File
@@ -0,0 +1,62 @@
"""Regression coverage for passwordless Google OAuth reminder senders."""
import asyncio
from pathlib import Path
from unittest.mock import patch
from routes.note_routes import dispatch_reminder
_REPO = Path(__file__).resolve().parents[1]
def test_dispatch_reminder_sends_with_google_oauth_without_smtp_password():
cfg = {
"account_name": "Workspace",
"smtp_host": "smtp.gmail.com",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_user": "alice@example.edu",
"smtp_password": "",
"from_address": "alice@example.edu",
"oauth_provider": "google",
}
sent = []
def fake_send(actual_cfg, sender, recipients, message):
sent.append((actual_cfg, sender, recipients, message))
with (
patch("src.settings.load_settings", return_value={}),
patch("routes.email_routes._get_email_config", return_value=cfg),
patch("routes.email_helpers._send_smtp_message", side_effect=fake_send),
patch("core.database.SessionLocal", side_effect=AssertionError("fallback lookup is not needed")),
):
result = asyncio.run(dispatch_reminder(
"Reminder: Submit report",
"The report is due today.",
note_id="",
owner="alice@example.edu",
queue_browser=False,
settings_override={
"reminder_channel": "email",
"reminder_llm_synthesis": False,
},
))
assert result["email_sent"] is True
assert result["email_error"] == ""
assert len(sent) == 1
actual_cfg, sender, recipients, message = sent[0]
assert actual_cfg is cfg
assert sender == "alice@example.edu"
assert recipients == ["alice@example.edu"]
assert "Subject: Reminder (Odysseus): Submit report" in message
def test_reminder_settings_offer_oauth_smtp_accounts():
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
helper = source[source.index("const smtpAccountReady"):source.index("const smtpAccountReady") + 260]
assert "account.has_smtp_password || account.oauth_provider === 'google'" in helper
assert source.count(".filter(smtpAccountReady)") == 2
+43
View File
@@ -0,0 +1,43 @@
"""Regression test for the note route shim (slice 2f, #4082/#4071).
The backward-compat shim at ``routes/note_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.note.*``
path resolve to the *same* module object. This is required because
``test_note_reminder_fire_scope.py`` and ``test_notes_fail_closed_auth.py``
do ``import routes.note_routes as note_routes`` followed by
``monkeypatch.setattr(note_routes, "SessionLocal", ...)`` for those patches
to take effect at runtime, the legacy module object and the canonical one
must be identical. This test pins that contract.
"""
import importlib
import routes.note_routes as _shim_note # noqa: F401
def test_legacy_and_canonical_note_module_are_same_object():
"""``import routes.note_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.note_routes")
canonical = importlib.import_module("routes.note.note_routes")
assert legacy is canonical, (
"routes.note_routes shim must resolve to the canonical "
"routes.note.note_routes module object"
)
def test_monkeypatch_via_legacy_alias_reaches_canonical(monkeypatch):
"""Patching through the legacy alias must reach the canonical module.
Several note tests do ``import routes.note_routes as note_routes``
followed by ``monkeypatch.setattr(note_routes, "SessionLocal", ...)``.
For that to take effect at runtime, the legacy module object and the
canonical one must be identical.
"""
legacy = importlib.import_module("routes.note_routes")
canonical = importlib.import_module("routes.note.note_routes")
sentinel = object()
monkeypatch.setattr(legacy, "setup_note_routes", sentinel)
assert canonical.setup_note_routes is sentinel, (
"monkeypatch via legacy alias did not reach the canonical module"
)
+40 -12
View File
@@ -1,13 +1,9 @@
"""A plain text message that merely *looks* like a JSON array of objects must
NOT be silently re-parsed into a list on reload.
"""Persistence contracts for JSON-like text and multimodal chat content.
_parse_msg_content de-serializes multimodal (image/audio) content back into a
list of content blocks. The old heuristic accepted ANY string that started
with "[{" and contained the substring '"type"'. A user who pasted an API
schema / sample such as `[{"type": "object", "name": "foo"}]` therefore had
their text message permanently corrupted into a Python list on the next
session hydration. The fix restricts the round-trip to lists whose elements
are all recognized content-block types (text/image_url/audio/...).
Plain text that resembles a JSON content-block list must remain an exact
string. Real provider multimodal blocks follow the durable attachment
contract: readable text plus stable attachment metadata is persisted, while
raw inline media bytes are omitted.
"""
import tempfile
import uuid
@@ -65,16 +61,48 @@ def test_jsonlike_user_string_not_corrupted(manager):
assert reloaded.history[0].content == text
def test_real_multimodal_content_still_round_trips(manager):
def test_real_multimodal_content_persists_reference_without_base64(manager):
sid = "sess-" + uuid.uuid4().hex[:8]
_make_session(sid)
attachment_id = "a" * 32 + ".png"
multimodal = [
{"type": "text", "text": "what is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
msgs = [ChatMessage(role="user", content=multimodal)]
metadata = {
"attachments": [
{
"id": attachment_id,
"name": "diagram.png",
"mime": "image/png",
"size": 4,
"checksum_sha256": "sha256-digest",
}
]
}
msgs = [ChatMessage(role="user", content=multimodal, metadata=metadata)]
assert manager.replace_messages(sid, msgs) is True
expected = (
"what is this?\n"
"[1 inline media payload omitted]\n"
f"[Attachment: diagram.png | id={attachment_id} | mime=image/png | "
"size=4 bytes | sha256=sha256-digest]"
)
db = _TS()
try:
stored = db.query(cdb.ChatMessage).filter_by(session_id=sid).one()
assert stored.content == expected
assert "what is this?" in stored.content
assert attachment_id in stored.content
assert "data:image/png;base64,AAAA" not in stored.content
assert "base64" not in stored.content
assert "AAAA" not in stored.content
finally:
db.close()
manager.sessions.clear()
reloaded = manager.get_session(sid)
assert reloaded.history[0].content == multimodal
assert reloaded.history[0].content == expected
assert reloaded.history[0].metadata["attachments"][0]["id"] == attachment_id
+63
View File
@@ -0,0 +1,63 @@
"""Regression guard for #5559 — the KEYWORD index (load_personal_index, which
PersonalDocsManager.refresh_index builds from) must skip hidden dirs, hidden
files, and junk dirs at ANY depth, the same as the vector index. Both walkers
share one pruning helper (src/index_walk) so they cannot drift again.
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
from src.personal_docs import load_personal_index
def _write(path, content="real content"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _indexed(root):
return {rec["name"] for rec in load_personal_index(str(root))}
def test_keyword_index_skips_hidden_and_junk_dirs(tmp_path):
_write(tmp_path / "note.md")
_write(tmp_path / "sub" / "deeper.md")
_write(tmp_path / ".obsidian" / "workspace.json")
_write(tmp_path / ".git" / "hooks.md")
_write(tmp_path / "node_modules" / "lib" / "readme.md")
_write(tmp_path / "__pycache__" / "cached.txt")
_write(tmp_path / "venv" / "lib" / "site.txt")
assert _indexed(tmp_path) == {"note.md", os.path.join("sub", "deeper.md")}
def test_keyword_index_skips_hidden_files(tmp_path):
_write(tmp_path / "visible.md")
_write(tmp_path / ".hidden.md")
_write(tmp_path / "sub" / ".secret.txt")
assert _indexed(tmp_path) == {"visible.md"}
def test_keyword_index_prunes_junk_at_depth(tmp_path):
"""Pruning must apply at every level, not just the first (the vector test's
fixtures only nested one level under the root)."""
_write(tmp_path / "a" / "b" / "keep.md")
_write(tmp_path / "a" / "b" / "node_modules" / "dep.md")
_write(tmp_path / "a" / ".obsidian" / "deep.json")
assert _indexed(tmp_path) == {os.path.join("a", "b", "keep.md")}
def test_keyword_index_junk_match_is_case_insensitive(tmp_path):
"""A case-variant junk dir must still be pruned (macOS default FS is
case-insensitive, so `Node_Modules` and `node_modules` are the same dir)."""
_write(tmp_path / "keep.md")
_write(tmp_path / "Node_Modules" / "dep.md")
assert _indexed(tmp_path) == {"keep.md"}
def test_keyword_index_explicit_hidden_root_still_indexed(tmp_path):
"""Children-only pruning: pointing indexing at a hidden dir gets its
contents, minus nested hidden/junk."""
root = tmp_path / ".notes"
_write(root / "idea.md")
_write(root / ".obsidian" / "plugin.json")
assert {rec["name"] for rec in load_personal_index(str(root))} == {"idea.md"}
+78
View File
@@ -0,0 +1,78 @@
"""Regression guard for #5559 — directory indexing must skip hidden directories,
hidden files, and well-known junk directories.
VectorRAG.index_personal_documents walked the whole tree with no pruning, so
pointing RAG at a real-world folder (an Obsidian vault, a git repo) swept in
`.obsidian/` plugin JavaScript, `.git/` internals, `node_modules/`, etc. The
junk multiplied indexing time and polluted retrieval.
These tests are hermetic no chromadb; VectorRAG is created via __new__ (skip
Chroma connect) with add_document stubbed to record which files get indexed.
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import src.rag_vector as rag_vector
def _make_rag(recorded_sources):
rag = rag_vector.VectorRAG.__new__(rag_vector.VectorRAG) # skip Chroma connect
def _record(text, metadata):
recorded_sources.add(metadata["source"])
return True
rag.add_document = _record
return rag
def _write(path, content="some real content"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_index_skips_hidden_and_junk_directories(tmp_path):
_write(tmp_path / "note.md")
_write(tmp_path / "sub" / "deeper.md")
_write(tmp_path / ".obsidian" / "plugins" / "plugin.js")
_write(tmp_path / ".git" / "hooks.js")
_write(tmp_path / "node_modules" / "lib" / "index.js")
_write(tmp_path / "__pycache__" / "cached.py")
_write(tmp_path / "venv" / "lib" / "site.py")
recorded = set()
rag = _make_rag(recorded)
result = rag.index_personal_documents(str(tmp_path))
assert result["success"] is True
indexed = {os.path.relpath(p, str(tmp_path)) for p in recorded}
assert indexed == {"note.md", os.path.join("sub", "deeper.md")}
def test_index_skips_hidden_files(tmp_path):
_write(tmp_path / "visible.md")
_write(tmp_path / ".hidden.md")
_write(tmp_path / "sub" / ".secret.txt")
recorded = set()
rag = _make_rag(recorded)
rag.index_personal_documents(str(tmp_path))
indexed = {os.path.relpath(p, str(tmp_path)) for p in recorded}
assert indexed == {"visible.md"}
def test_explicitly_passed_hidden_root_is_still_indexed(tmp_path):
"""Pruning applies to children only — a user who deliberately points RAG at
a hidden directory gets its contents, minus nested hidden/junk dirs."""
root = tmp_path / ".notes"
_write(root / "idea.md")
_write(root / ".obsidian" / "plugin.js")
recorded = set()
rag = _make_rag(recorded)
rag.index_personal_documents(str(root))
indexed = {os.path.relpath(p, str(root)) for p in recorded}
assert indexed == {"idea.md"}
+51 -17
View File
@@ -1,14 +1,9 @@
"""replace_messages must JSON-serialize multimodal (list) content.
"""replace_messages must persist readable, path-free multimodal history.
A chat with an image/audio attachment carries list content. When such a
chat is compacted, the manual-compaction path calls replace_messages with
the retained messages. replace_messages wrote message.content straight into
the Text column, so SQLAlchemy bound the list\'s single-quoted repr. On
reload _parse_msg_content only de-serializes a string that contains the
double-quoted "type", so the repr failed the check and the message came
back as a corrupted string blob - the attachment was destroyed. The
sibling _persist_message json.dumps-es list content; replace_messages did
not.
Live model input may contain provider-specific media blocks and inline data
URLs. Compaction uses replace_messages for the retained transcript, which must
store readable text plus stable structured attachment references without
copying raw base64 payloads into ChatMessage.content.
"""
import uuid
@@ -27,6 +22,7 @@ def manager(monkeypatch):
monkeypatch.setattr(sm, "SessionLocal", _TS)
mgr = sm.SessionManager.__new__(sm.SessionManager)
mgr.sessions = {}
mgr.upload_handler = None
return mgr
@@ -41,33 +37,71 @@ def _make_session(sid, owner="alice"):
db.close()
def test_multimodal_content_round_trips_through_replace_messages(manager):
def test_multimodal_content_persists_text_and_attachment_ref_without_payload(manager):
sid = "sess-" + uuid.uuid4().hex[:8]
_make_session(sid)
upload_id = "a" * 32 + ".png"
multimodal = [
{"type": "text", "text": "what is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
msgs = [ChatMessage(role="user", content=multimodal)]
msgs = [ChatMessage(
role="user",
content=multimodal,
metadata={
"attachments": [{
"id": upload_id,
"name": "diagram.png",
"mime": "image/png",
"size": 4,
"checksum_sha256": "sha256-digest",
}]
},
)]
assert manager.replace_messages(sid, msgs) is True
expected = (
"what is this?\n"
"[1 inline media payload omitted]\n"
f"[Attachment: diagram.png | id={upload_id} | mime=image/png | "
"size=4 bytes | sha256=sha256-digest]"
)
db = _TS()
try:
stored = db.query(cdb.ChatMessage).filter_by(session_id=sid).one()
assert stored.content == expected
assert "data:image/png;base64,AAAA" not in stored.content
assert "base64" not in stored.content
assert "AAAA" not in stored.content
finally:
db.close()
# Drop the in-memory cache so the next read hydrates from the DB.
manager.sessions.clear()
reloaded = manager.get_session(sid)
assert len(reloaded.history) == 1
# Content must come back as the original list, not a repr string blob.
assert reloaded.history[0].content == multimodal
persisted = reloaded.history[0].content
assert isinstance(persisted, str)
assert persisted == expected
assert reloaded.history[0].metadata["attachments"][0]["id"] == upload_id
assert (
reloaded.history[0].metadata["attachments"][0]["checksum_sha256"]
== "sha256-digest"
)
def test_plain_string_content_still_round_trips(manager):
def test_jsonlike_plain_string_content_still_round_trips(manager):
sid = "sess-" + uuid.uuid4().hex[:8]
_make_session(sid)
msgs = [ChatMessage(role="user", content="just text")]
text = '[{"type": "object", "name": "foo"}]'
msgs = [ChatMessage(role="user", content=text)]
assert manager.replace_messages(sid, msgs) is True
manager.sessions.clear()
reloaded = manager.get_session(sid)
assert reloaded.history[0].content == "just text"
assert isinstance(reloaded.history[0].content, str)
assert reloaded.history[0].content == text
def test_replace_messages_keeps_history_alias_for_context_messages(manager):
@@ -0,0 +1,259 @@
"""Upload lifecycle guarantees for compaction's replace_messages path."""
import concurrent.futures
import json
import os
import threading
import uuid
import pytest
from sqlalchemy import event
import core.database as cdb
import core.session_manager as session_manager_module
from core.models import ChatMessage
from src.upload_handler import UploadHandler
from tests.helpers.sqlite_db import make_temp_sqlite
OLD_TIMESTAMP = "2000-01-01T00:00:00"
@pytest.fixture
def manager_db(monkeypatch):
SessionLocal, engine, tmpfile = make_temp_sqlite(cdb.Base.metadata)
monkeypatch.setattr(session_manager_module, "SessionLocal", SessionLocal)
manager = session_manager_module.SessionManager.__new__(
session_manager_module.SessionManager
)
manager.sessions = {}
manager.upload_handler = None
try:
yield manager, SessionLocal, engine
finally:
engine.dispose()
tmpfile.close()
try:
os.unlink(tmpfile.name)
except OSError:
pass
def _seed_session(SessionLocal, *, owner="alice", content="existing durable history"):
session_id = "replace-" + uuid.uuid4().hex
db = SessionLocal()
try:
db.add(cdb.Session(
id=session_id,
owner=owner,
name="Compaction reservation regression",
model="test-model",
endpoint_url="http://localhost:11434",
archived=False,
message_count=1,
))
db.add(cdb.ChatMessage(
id="message-" + uuid.uuid4().hex,
session_id=session_id,
role="user",
content=content,
meta_data=json.dumps({"source": "before-replacement"}),
))
db.commit()
finally:
db.close()
return session_id
def _attachment_message(upload_id, text):
return ChatMessage(
role="user",
content=text,
metadata={
"attachments": [{
"id": upload_id,
"name": f"{text}.txt",
"mime": "text/plain",
"size": len(text),
}]
},
)
def _durable_messages(SessionLocal, session_id):
db = SessionLocal()
try:
return [
(message.role, message.content, message.meta_data)
for message in db.query(cdb.ChatMessage)
.filter(cdb.ChatMessage.session_id == session_id)
.order_by(cdb.ChatMessage.timestamp, cdb.ChatMessage.id)
.all()
]
finally:
db.close()
def test_replace_messages_reserves_every_incoming_attachment_before_delete(
manager_db,
monkeypatch,
):
manager, SessionLocal, engine = manager_db
session_id = _seed_session(SessionLocal)
manager.upload_handler = object()
incoming = [
_attachment_message("1" * 32 + ".txt", "first"),
_attachment_message("2" * 32 + ".txt", "second"),
]
message_mutations = []
reservation_calls = []
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
normalized = statement.lstrip().upper()
if normalized.startswith(("DELETE FROM CHAT_MESSAGES", "INSERT INTO CHAT_MESSAGES")):
message_mutations.append(normalized.split(maxsplit=1)[0])
def reserve(handler, owner, content, metadata):
assert message_mutations == []
reservation_calls.append((handler, owner, content, metadata))
return None
event.listen(engine, "before_cursor_execute", record_sql)
monkeypatch.setattr(
session_manager_module,
"reserve_message_upload_references",
reserve,
)
try:
assert manager.replace_messages(session_id, incoming) is True
finally:
event.remove(engine, "before_cursor_execute", record_sql)
assert [call[2] for call in reservation_calls] == ["first", "second"]
assert all(call[0] is manager.upload_handler for call in reservation_calls)
assert all(call[1] == "alice" for call in reservation_calls)
assert message_mutations == ["DELETE", "INSERT"]
def test_replace_messages_reservation_failure_leaves_durable_history_unchanged(
manager_db,
monkeypatch,
):
manager, SessionLocal, engine = manager_db
session_id = _seed_session(SessionLocal)
before = _durable_messages(SessionLocal, session_id)
manager.upload_handler = object()
missing_upload_id = "4" * 32 + ".txt"
incoming = [
_attachment_message("3" * 32 + ".txt", "available"),
_attachment_message(missing_upload_id, "missing"),
]
calls = []
message_mutations = []
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
normalized = statement.lstrip().upper()
if normalized.startswith(("DELETE FROM CHAT_MESSAGES", "INSERT INTO CHAT_MESSAGES")):
message_mutations.append(normalized.split(maxsplit=1)[0])
def reserve(_handler, _owner, content, _metadata):
calls.append(content)
return missing_upload_id if content == "missing" else None
event.listen(engine, "before_cursor_execute", record_sql)
monkeypatch.setattr(
session_manager_module,
"reserve_message_upload_references",
reserve,
)
try:
assert manager.replace_messages(session_id, incoming) is False
finally:
event.remove(engine, "before_cursor_execute", record_sql)
assert calls == ["available", "missing"]
assert message_mutations == []
assert _durable_messages(SessionLocal, session_id) == before
assert [message.content for message in manager.sessions[session_id].history] == [
"existing durable history"
]
assert all("_db_id" not in (message.metadata or {}) for message in incoming)
def test_cleanup_cannot_delete_attachment_during_concurrent_compaction_replacement(
manager_db,
monkeypatch,
tmp_path,
):
manager, SessionLocal, _engine = manager_db
session_id = _seed_session(SessionLocal)
base_dir = tmp_path / "base"
upload_dir = tmp_path / "uploads"
base_dir.mkdir()
upload_dir.mkdir()
handler = UploadHandler(str(base_dir), str(upload_dir))
manager.upload_handler = handler
upload_id = "5" * 32 + ".txt"
upload_hash = "6" * 64
dated_dir = upload_dir / "2000" / "01" / "01"
dated_dir.mkdir(parents=True)
upload_path = dated_dir / upload_id
upload_path.write_text("attachment retained by compaction", encoding="utf-8")
upload_row = {
"id": upload_id,
"path": str(upload_path),
"mime": "text/plain",
"size": upload_path.stat().st_size,
"name": "compaction.txt",
"original_name": "compaction.txt",
"hash": upload_hash,
"checksum_sha256": upload_hash,
"uploaded_at": OLD_TIMESTAMP,
"created_at": OLD_TIMESTAMP,
"last_accessed": OLD_TIMESTAMP,
"owner": "alice",
}
(upload_dir / "uploads.json").write_text(
json.dumps({f"alice:{upload_hash}": upload_row}),
encoding="utf-8",
)
handler._index_cache = None
reservation_write_entered = threading.Event()
release_reservation_write = threading.Event()
real_atomic_write = handler._atomic_write_json
def block_reservation_write(path, data, *, sync_backup=False):
refreshed = any(
isinstance(row, dict)
and row.get("id") == upload_id
and row.get("last_accessed") != OLD_TIMESTAMP
for row in data.values()
)
if sync_backup and refreshed and not reservation_write_entered.is_set():
reservation_write_entered.set()
assert release_reservation_write.wait(5)
return real_atomic_write(path, data, sync_backup=sync_backup)
monkeypatch.setattr(handler, "_atomic_write_json", block_reservation_write)
incoming = [_attachment_message(upload_id, "retained after compaction")]
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
replace_future = pool.submit(manager.replace_messages, session_id, incoming)
assert reservation_write_entered.wait(5)
cleanup_future = pool.submit(handler.cleanup_old_uploads, set(), set())
try:
with pytest.raises(concurrent.futures.TimeoutError):
cleanup_future.result(timeout=0.1)
finally:
release_reservation_write.set()
assert replace_future.result(timeout=5) is True
assert cleanup_future.result(timeout=5) == 0
assert upload_path.is_file()
assert handler.resolve_upload(upload_id, owner="alice") is not None
durable = _durable_messages(SessionLocal, session_id)
assert len(durable) == 1
assert json.loads(durable[0][2])["attachments"][0]["id"] == upload_id
+77 -1
View File
@@ -354,6 +354,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
chat_helpers = importlib.import_module("routes.chat_helpers")
chat_helpers._INCOGNITO_CONTEXTS.clear()
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
# **kwargs absorbs auto_opened_docs (added when PDF imports auto-create
@@ -417,6 +418,68 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
assert len(user_messages) == 1
@pytest.mark.asyncio
async def test_build_chat_context_incognito_ignores_saved_session_history(monkeypatch):
for mod_name in [
"starlette.middleware",
"starlette.middleware.base",
"core.models",
"core.database",
"routes.prefs_routes",
"routes.research_routes",
"src.llm_core",
"src.context_compactor",
"src.model_context",
"src.auth_helpers",
]:
if mod_name not in sys.modules:
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
chat_helpers = importlib.import_module("routes.chat_helpers")
chat_helpers._INCOGNITO_CONTEXTS.clear()
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
return chat_helpers.PreprocessedMessage(
enhanced_message=message,
user_content=message,
text_for_context=message,
youtube_transcripts=[],
attachment_meta=[],
)
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
return messages, 123, False
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
monkeypatch.setattr(chat_helpers, "extract_preset", lambda *_args, **_kwargs: chat_helpers.PresetInfo(0.7, 1024, None, None))
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
monkeypatch.setattr(chat_helpers, "effective_user", lambda request: "tester")
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
sess = SimpleNamespace(
endpoint_url="http://localhost:8000/v1",
model="test-model",
headers={},
get_context_messages=lambda: [{"role": "user", "content": "older non-incognito secret"}],
)
chat_processor = SimpleNamespace(build_context_preface=lambda **kwargs: ([], [], []))
ctx = await chat_helpers.build_chat_context(
sess=sess,
request=SimpleNamespace(),
chat_handler=SimpleNamespace(),
chat_processor=chat_processor,
message="fresh incognito turn",
session_id="s-incog",
incognito=True,
)
assert {"role": "user", "content": "fresh incognito turn"} in ctx.messages
assert all(m.get("content") != "older non-incognito secret" for m in ctx.messages)
@pytest.mark.asyncio
async def test_admin_agent_tools_require_admin(monkeypatch):
auth_mod = _install_core_auth_stub(monkeypatch)
@@ -648,6 +711,7 @@ async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
# here instead of silently shrinking the blocklist.
bare_email_tools = (
"list_email_accounts", "list_emails", "read_email", "search_emails",
"scan_email_unsubscribes", "unsubscribe_email",
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
"ai_draft_email_reply", "archive_email", "delete_email",
"mark_email_read", "bulk_email", "download_attachment",
@@ -758,6 +822,7 @@ async def test_disable_tool_email_covers_full_builtin_set(monkeypatch):
# from the constant fails here instead of silently shrinking the toggle.
bare_email_tools = (
"list_email_accounts", "list_emails", "read_email", "search_emails",
"scan_email_unsubscribes", "unsubscribe_email",
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
"ai_draft_email_reply", "archive_email", "delete_email",
"mark_email_read", "bulk_email", "download_attachment",
@@ -930,7 +995,7 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
denied = plan_mode_disabled_tools()
for tool_name in ("draft_email", "draft_email_reply", "ai_draft_email_reply",
"download_attachment", "send_email", "delete_email"):
"download_attachment", "send_email", "delete_email", "unsubscribe_email"):
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=tool_name, content="{}"),
owner="admin-user",
@@ -949,6 +1014,17 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
("mcp__email__search_emails", {"query": "x", "_odysseus_owner": "admin-user"}),
]
mcp.calls.clear()
desc, result = await execute_tool_block(
SimpleNamespace(tool_type="scan_email_unsubscribes", content='{"limit": 1}'),
owner="admin-user",
disabled_tools=denied,
)
assert result["exit_code"] == 0
assert mcp.calls == [
("mcp__email__scan_email_unsubscribes", {"limit": 1, "_odysseus_owner": "admin-user"}),
]
@pytest.mark.asyncio
async def test_bare_email_dispatch_empty_content_calls_with_empty_args(monkeypatch):
+1 -1
View File
@@ -4,7 +4,7 @@ Providers like Moonshot (Kimi K2.5/K2.6) require reasoning_content on
assistant tool-call messages. Stripping it causes HTTP 400 in multi-turn
tool calling when thinking mode is enabled.
See: https://github.com/pewdiepie-archdaemon/odysseus/issues/3118
See: https://github.com/odysseus-dev/odysseus/issues/3118
"""
import sys
from unittest.mock import MagicMock
+116
View File
@@ -0,0 +1,116 @@
"""Regression: two concurrent callers of `_scheduled_poll_once` (the
in-process 30s poller and the `odysseus-mail poll-scheduled` CLI, which the
project's own docstrings warn can race on the same SQLite when
ODYSSEUS_INPROCESS_POLLERS is left enabled alongside an external cron/systemd
driver) must not both send the same scheduled email.
The old code selected pending rows, then only updated their status to 'sent'
*after* the SMTP send completed - two overlapping calls can both SELECT the
same 'pending' row before either UPDATEs it, so both send it. The fix adds
an atomic claim step (`UPDATE ... SET status='sending' WHERE status='pending'`)
before any work happens; only the caller whose UPDATE actually changes a row
proceeds, the other sees rowcount == 0 and skips it.
This test drives two real threads through the real `_scheduled_poll_once`
against a shared SQLite file, synchronized with a barrier so both reach the
SELECT at (as close to) the same moment as possible, and asserts the send
callback fired exactly once.
"""
import sqlite3
import threading
import time
def test_concurrent_pollers_do_not_double_send(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()
conn = sqlite3.connect(db_path)
conn.execute(
"""
INSERT INTO scheduled_emails
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(
"sched-race-1",
"recipient@example.com",
"Subject",
"Body",
"[]",
"2000-01-01T00:00:00",
"1999-12-31T00:00:00",
"acct-alice",
"alice",
),
)
conn.commit()
conn.close()
send_calls = []
send_lock = threading.Lock()
barrier = threading.Barrier(2)
def fake_get_email_config(account_id=None, owner=""):
return {
"from_address": "alice@example.com",
"smtp_host": "smtp.example.com",
"smtp_user": "alice@example.com",
"smtp_password": "secret",
}
def fake_send_smtp_message(*args, **kwargs):
# Widen the window between the claim and the actual send so a
# buggy (unclaimed) second poller has every opportunity to also
# get past its SELECT and attempt to send.
time.sleep(0.05)
with send_lock:
send_calls.append(threading.get_ident())
class FakeImap:
def __init__(self, account_id=None, owner=""):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def append(self, folder, flags, date_time, message):
pass
monkeypatch.setattr(email_pollers, "_get_email_config", fake_get_email_config)
monkeypatch.setattr(email_pollers, "_send_smtp_message", fake_send_smtp_message)
monkeypatch.setattr(email_pollers, "_imap", FakeImap)
monkeypatch.setattr(email_pollers, "_detect_sent_folder", lambda imap: "Sent")
monkeypatch.setattr(email_pollers, "_cleanup_compose_uploads", lambda attachments: None)
results = []
def _run():
barrier.wait()
results.append(email_pollers._scheduled_poll_once())
threads = [threading.Thread(target=_run) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert len(send_calls) == 1, (
f"expected exactly one send for the racing pollers, got {len(send_calls)}: "
"the second poller must lose the atomic claim and skip the row"
)
conn = sqlite3.connect(db_path)
status = conn.execute(
"SELECT status FROM scheduled_emails WHERE id=?", ("sched-race-1",)
).fetchone()[0]
conn.close()
assert status == "sent"
+77
View File
@@ -0,0 +1,77 @@
import json
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from core.database import Base, ChatMessage, GalleryImage, Session
from src import session_image_cleanup
def test_cleanup_session_images_deactivates_gallery_rows_and_unlinks_files(tmp_path, monkeypatch):
image_dir = tmp_path / "generated_images"
image_dir.mkdir()
linked_file = image_dir / "aaaaaaaaaaaa.png"
event_file = image_dir / "bbbbbbbbbbbb.png"
linked_file.write_bytes(b"linked")
event_file.write_bytes(b"event")
monkeypatch.setattr(session_image_cleanup, "GENERATED_IMAGES_DIR", str(image_dir))
engine = create_engine(
f"sqlite:///{tmp_path / 'cleanup.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = SessionLocal()
try:
db.add(Session(id="chat-1", name="Image chat", endpoint_url="http://local", model="image-model", owner="alice"))
db.add(
GalleryImage(
id="img-linked",
filename=linked_file.name,
prompt="linked",
owner="alice",
session_id="chat-1",
is_active=True,
)
)
db.add(
GalleryImage(
id="img-event",
filename=event_file.name,
prompt="event",
owner="alice",
is_active=True,
)
)
db.add(
ChatMessage(
id="msg-1",
session_id="chat-1",
role="assistant",
content="Generated image",
meta_data=json.dumps(
{
"tool_events": [
{
"image_id": "img-event",
"image_url": f"/api/generated-image/{event_file.name}",
}
]
}
),
)
)
db.commit()
removed = session_image_cleanup.cleanup_session_images("chat-1", db=db)
assert removed == 2
assert not linked_file.exists()
assert not event_file.exists()
assert db.query(GalleryImage).filter_by(id="img-linked").first().is_active is False
assert db.query(GalleryImage).filter_by(id="img-event").first().is_active is False
finally:
db.close()
+3 -3
View File
@@ -71,7 +71,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
with pytest.raises(SkillImportError, match="redirect target"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
@@ -91,7 +91,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
class _Resp:
@@ -146,7 +146,7 @@ def _mock_httpx_client(monkeypatch, response):
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
+123
View File
@@ -0,0 +1,123 @@
"""Skill importer SSRF hardening: redirects must be re-validated per hop.
The importer follows redirects manually (`_get_checked`) and re-runs the SSRF
guard on every hop with ``block_private=True``, matching the hardened web-fetch
path in ``services/search/content.py:_get_public_url``. Previously it used
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
URL only, so a ``3xx`` to an internal/metadata address was still connected to.
These tests are hermetic: every host is an IP literal, so ``check_outbound_url``
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
network access is required. The HTTP layer is faked so no real request is made.
"""
import pytest
from services.memory import skill_importer
from services.memory.skill_importer import (
SkillImportError,
_check_fetch_url,
_fetch_bytes,
_get_checked,
parse_skill_source,
)
# Clearly-public, non-reserved IP literals for the initial (allowed) hop.
PUBLIC_A = "https://1.1.1.1/skill"
PUBLIC_B = "https://8.8.8.8/skill"
# Internal redirect targets that must be refused before connection.
LOOPBACK = "http://127.0.0.1/latest"
METADATA = "http://169.254.169.254/latest/meta-data/"
def _install_fake_client(monkeypatch, *, redirect_from, redirect_to):
"""Replace httpx.Client so `redirect_from` 302s to `redirect_to`, and any
other URL returns 200. No real socket is opened."""
class _Resp:
def __init__(self, url, status, location):
self.url = url
self.status_code = status
self.headers = {"location": location} if location else {}
self.content = b"ok"
self.text = ""
def raise_for_status(self):
return None
def json(self):
return {}
class _Client:
def __init__(self, *args, **kwargs):
# Safety invariant: the importer follows redirects by hand and
# re-runs the SSRF guard per hop, so it MUST disable httpx's own
# redirect following. Asserting ``follow_redirects is False`` here
# (not merely accepting the kwarg) makes any regression to
# ``follow_redirects=True`` fail these tests instead of passing
# silently — httpx being faked would otherwise hide the change.
assert kwargs.get("follow_redirects") is False, (
"skill importer must construct httpx.Client with "
"follow_redirects=False; got "
f"{kwargs.get('follow_redirects')!r}"
)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
if url == redirect_from:
return _Resp(url, 302, redirect_to)
return _Resp(url, 200, None)
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
# --- Guard unit: block_private=True refuses internal, allows public ----------
@pytest.mark.parametrize("url", [LOOPBACK, METADATA, "http://10.0.0.5/", "http://[::1]/"])
def test_check_fetch_url_blocks_internal(url):
with pytest.raises(SkillImportError):
_check_fetch_url(url)
@pytest.mark.parametrize("url", [PUBLIC_A, PUBLIC_B])
def test_check_fetch_url_allows_public(url):
# Should not raise for a public IP literal.
_check_fetch_url(url)
# --- Redirect revalidation: the core regression ------------------------------
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
def test_get_checked_blocks_redirect_to_internal(monkeypatch, internal):
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
with pytest.raises(SkillImportError, match="blocked"):
_get_checked(PUBLIC_A)
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
# Higher-level: the public fetch helpers inherit the per-hop guard.
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
with pytest.raises(SkillImportError, match="blocked"):
_fetch_bytes(PUBLIC_A)
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
raw = "http://1.1.1.1/skills.sh" # contains "skills.sh", not "github.com"
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
with pytest.raises(SkillImportError, match="blocked"):
parse_skill_source(raw)
# --- Positive: a legitimate public->public redirect is still followed --------
def test_get_checked_follows_public_redirect(monkeypatch):
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=PUBLIC_B)
resp = _get_checked(PUBLIC_A)
assert resp.status_code == 200
assert str(resp.url) == PUBLIC_B
@@ -0,0 +1,15 @@
from services.research.research_handler import ResearchHandler
def test_extract_sources_skips_non_dict_findings():
# findings come from the DeepResearcher result list / cached JSON; a
# malformed entry (None or a bare string) made f.get crash and drop every
# real source.
findings = [
{"url": "https://a.com", "title": "A", "summary": "real analysis of the topic"},
"junk-row",
None,
{"url": "https://b.com", "summary": "more genuine detail here"},
]
out = ResearchHandler._extract_sources(findings)
assert [s["url"] for s in out] == ["https://a.com", "https://b.com"]
+1 -1
View File
@@ -24,7 +24,7 @@ import inspect
import src.tool_implementations as ti
# 33 do_* tool functions
# Historical do_* tool functions.
_EXPECTED = [
"do_adopt_served_model", "do_api_call", "do_app_api", "do_cancel_download",
"do_download_model", "do_edit_image", "do_list_cached_models",
+1 -1
View File
@@ -32,7 +32,7 @@ def test_tell_in_web_query_does_not_force_email_tools():
"""The #1707 repro: a web request that merely contains the word 'tell' must
NOT drag in the email toolset."""
ti = _index_without_embeddings()
q = "visit https://www.youtube.com/user/PewDiePie and tell me the title of his latest video"
q = "visit https://www.youtube.com/user/example and tell me the title of the latest video"
tools = ti.get_tools_for_query(q)
leaked = _EMAIL_TOOLS & tools
assert not leaked, f"'tell me' must not force-include email tools, got {sorted(leaked)}"
@@ -0,0 +1,92 @@
"""Regression: the tool-execution task inside stream_agent_loop must be
cancelled (not orphaned) when the SSE consumer stops draining the generator
early e.g. a client disconnect mid tool-call.
The drain loop in stream_agent_loop:
_tool_task = asyncio.create_task(_run_tool())
while True:
evt = await _progress_q.get()
if evt is None:
break
yield ...
desc, result = await _tool_task
used to have no try/finally around it. If the generator is closed while
suspended on `await _progress_q.get()` (which is exactly what Starlette does
via `aclose()` when an SSE client disconnects), GeneratorExit is thrown at
that point and `_tool_task` is abandoned mid-flight never awaited, never
cancelled. For a long-running `bash`/`python` tool this orphans the
subprocess server-side with nothing left to reap it.
The fix wraps the drain loop in try/finally and cancels+awaits `_tool_task`
on early exit. This test drives the real stream_agent_loop with a fake tool
handler that sleeps until cancelled, closes the generator mid-tool-call (the
same way a dropped SSE connection would), and asserts the fake handler
actually observed cancellation.
"""
import asyncio
import json
import src.agent_loop as al
def test_tool_task_cancelled_on_generator_close(monkeypatch):
cancelled = {"v": False}
async def _slow_exec(block, *a, progress_cb=None, **k):
if progress_cb:
await progress_cb({"elapsed_s": 1, "tail": "running"})
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
cancelled["v"] = True
raise
return ("bash", {"output": "ok", "exit_code": 0})
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
monkeypatch.setattr(al, "execute_tool_block", _slow_exec, raising=False)
native_calls = [{"name": "bash", "arguments": json.dumps({"command": "sleep 60"})}]
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": "Running it now."})}\n\n'
yield f'data: {json.dumps({"type": "tool_calls", "calls": native_calls})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
async def _run():
gen = al.stream_agent_loop(
"https://api.openai.com/v1", "gpt-4o",
[{"role": "user", "content": "run sleep 60"}],
max_rounds=2,
relevant_tools={"bash"},
)
saw_tool_start = False
saw_tool_progress = False
async for chunk in gen:
if '"type": "tool_start"' in chunk:
saw_tool_start = True
elif '"type": "tool_progress" ' in chunk or '"type": "tool_progress"' in chunk:
saw_tool_progress = True
break
assert saw_tool_start, "expected a tool_start event before the tool ran"
assert saw_tool_progress, "expected a tool_progress event once the fake tool started (task must exist by now)"
# Simulate an SSE client disconnecting mid tool-call: close the
# generator while it is suspended awaiting the next progress event.
await gen.aclose()
# Assert *inside* this coroutine, immediately after aclose() returns.
# asyncio.run()'s own shutdown sequence cancels any tasks still
# pending once _run() itself completes — checking after asyncio.run()
# returns would pass even with the bug, because that unrelated
# cleanup would cancel the orphaned task anyway and mask the fix.
assert cancelled["v"] is True, (
"tool task must be cancelled by stream_agent_loop's own cleanup "
"on generator close, not left running until asyncio.run() tears "
"down the loop"
)
asyncio.run(_run())
@@ -0,0 +1,17 @@
from services.tts.tts_service import TTSService
def test_available_tolerates_non_string_provider(tmp_path):
"""A hand-edited/corrupt data/settings.json can store a non-string
tts_provider (e.g. null or a number). available reads it and calls
provider.startswith("endpoint:"), which raised AttributeError on a
non-str. It must instead fall through and report unavailable."""
service = TTSService(cache_dir=str(tmp_path))
service._load_settings = lambda: {
"tts_enabled": True,
"tts_provider": 123,
"tts_model": "tts-1",
"tts_voice": "alloy",
"tts_speed": "1",
}
assert service.available is False
+831
View File
@@ -0,0 +1,831 @@
import asyncio
import concurrent.futures
import json
import os
import threading
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from core.database import (
Base,
ChatMessage as DbChatMessage,
CalendarCal,
CalendarEvent,
Document,
DocumentVersion,
GalleryImage,
Note,
Session as DbSession,
)
from src.upload_handler import (
UploadCleanupSafetyError,
UploadHandler,
extract_internal_upload_ids,
reserve_message_upload_references,
reserve_upload_references,
)
from tests.helpers.sqlite_db import make_temp_sqlite
OLD_TIMESTAMP = "2000-01-01T00:00:00"
class _AdminAuth:
is_configured = True
@staticmethod
def is_admin(user):
return user == "admin"
class _AdminRequest:
headers = {}
state = SimpleNamespace(current_user="admin")
app = SimpleNamespace(state=SimpleNamespace(auth_manager=_AdminAuth()))
def _make_handler(tmp_path: Path) -> UploadHandler:
base_dir = tmp_path / "base"
upload_dir = tmp_path / "uploads"
base_dir.mkdir()
upload_dir.mkdir()
return UploadHandler(str(base_dir), str(upload_dir))
def _seed_old_uploads(handler: UploadHandler, rows: list[dict]) -> dict[str, Path]:
dated_dir = Path(handler.upload_dir) / "2000" / "01" / "01"
dated_dir.mkdir(parents=True)
index = {}
paths = {}
for row in rows:
upload_id = row["id"]
path = dated_dir / upload_id
path.write_bytes(row.get("bytes", upload_id.encode("ascii")))
info = {
"id": upload_id,
"path": str(path),
"mime": row.get("mime", "application/octet-stream"),
"size": path.stat().st_size,
"name": row.get("name", upload_id),
"original_name": row.get("name", upload_id),
"hash": row["hash"],
"checksum_sha256": row["hash"],
"uploaded_at": row.get("uploaded_at", OLD_TIMESTAMP),
"created_at": row.get("created_at", OLD_TIMESTAMP),
"last_accessed": row.get("last_accessed", OLD_TIMESTAMP),
"owner": row.get("owner", "alice"),
}
index[f"{info['owner']}:{info['hash']}"] = info
paths[upload_id] = path
Path(handler.upload_dir, "uploads.json").write_text(
json.dumps(index),
encoding="utf-8",
)
handler._index_cache = None
return paths
def _manual_cleanup_endpoint(handler: UploadHandler, monkeypatch):
import fastapi.dependencies.utils as dependency_utils
from routes.upload_routes import router, setup_upload_routes
monkeypatch.setattr(dependency_utils, "ensure_multipart_is_installed", lambda: None)
before = len(router.routes)
setup_upload_routes(handler)
return {
route.endpoint.__name__: route.endpoint
for route in router.routes[before:]
}["manual_cleanup"]
def _reference_database(monkeypatch, *, upload_id: str, gallery_hash: str = None):
from routes import upload_routes
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
db = SessionLocal()
try:
db.add(DbSession(
id="session-1",
name="Cleanup regression",
endpoint_url="http://localhost",
model="test-model",
owner="alice",
))
db.add(DbChatMessage(
id="message-1",
session_id="session-1",
role="user",
content=f"[Attachment: retained.png | id={upload_id} | mime=image/png]",
meta_data=json.dumps({
"attachments": [{
"id": upload_id,
"name": "retained.png",
"mime": "image/png",
"size": 8,
}]
}),
))
if gallery_hash:
db.add(GalleryImage(
id="gallery-cleanup-reference",
filename="abcdef123456.png",
prompt="Chat upload",
owner="alice",
file_hash=gallery_hash,
))
db.commit()
finally:
db.close()
monkeypatch.setattr(upload_routes, "SessionLocal", SessionLocal)
return engine, tmpfile
def test_admin_cleanup_preserves_referenced_upload_and_reconciles_deleted_row(
tmp_path,
monkeypatch,
):
handler = _make_handler(tmp_path)
referenced_id = "a" * 32 + ".png"
unreferenced_id = "b" * 32 + ".txt"
gallery_id = "7" * 32 + ".png"
gallery_hash = "7" * 64
paths = _seed_old_uploads(handler, [
{
"id": referenced_id,
"hash": "1" * 64,
"mime": "image/png",
},
{
"id": unreferenced_id,
"hash": "2" * 64,
"mime": "text/plain",
},
{
"id": gallery_id,
"hash": gallery_hash,
"mime": "image/png",
},
])
engine, tmpfile = _reference_database(
monkeypatch,
upload_id=referenced_id,
gallery_hash=gallery_hash,
)
try:
response = asyncio.run(
_manual_cleanup_endpoint(handler, monkeypatch)(_AdminRequest())
)
finally:
engine.dispose()
tmpfile.close()
try:
os.unlink(tmpfile.name)
except OSError:
pass
assert response == {"status": "success", "files_cleaned": 1}
assert paths[referenced_id].is_file()
referenced_info = handler.get_upload_info(referenced_id)
assert referenced_info is not None
assert handler.resolve_upload(referenced_id, owner="alice") is not None
assert paths[gallery_id].is_file()
assert handler.get_upload_info(gallery_id) is not None
assert not paths[unreferenced_id].exists()
assert handler.get_upload_info(unreferenced_id) is None
assert handler.resolve_upload(unreferenced_id, owner="alice") is None
live_index = json.loads(
Path(handler.upload_dir, "uploads.json").read_text(encoding="utf-8")
)
assert {info["id"] for info in live_index.values()} == {
referenced_id,
gallery_id,
}
backup_index = json.loads(
Path(handler.upload_dir, "uploads.json.bak").read_text(encoding="utf-8")
)
assert {info["id"] for info in backup_index.values()} == {
referenced_id,
gallery_id,
}
# Recovery must not resurrect the deliberately deleted row.
Path(handler.upload_dir, "uploads.json").write_text("{broken", encoding="utf-8")
handler._index_cache = None
assert handler.get_upload_info(unreferenced_id) is None
assert paths[referenced_id].parent.is_dir()
def test_cleanup_retains_upload_and_all_rows_when_index_rows_disagree(tmp_path):
handler = _make_handler(tmp_path)
upload_id = "c" * 32 + ".txt"
path = _seed_old_uploads(handler, [{
"id": upload_id,
"hash": "1" * 64,
"mime": "text/plain",
"owner": "alice",
}])[upload_id]
index_path = Path(handler.upload_dir, "uploads.json")
index = json.loads(index_path.read_text(encoding="utf-8"))
alice_row = next(iter(index.values()))
index["bob:" + "2" * 64] = {
**alice_row,
"owner": "bob",
"hash": "2" * 64,
"checksum_sha256": "2" * 64,
}
index_path.write_text(json.dumps(index), encoding="utf-8")
handler._index_cache = None
assert handler.cleanup_old_uploads(set(), set()) == 0
assert path.is_file()
assert json.loads(index_path.read_text(encoding="utf-8")) == index
def test_cleanup_retains_lone_row_without_authoritative_lifecycle_metadata(tmp_path):
handler = _make_handler(tmp_path)
upload_id = "6" * 32 + ".txt"
path = _seed_old_uploads(handler, [{
"id": upload_id,
"hash": "6" * 64,
"mime": "text/plain",
}])[upload_id]
index_path = Path(handler.upload_dir, "uploads.json")
index = json.loads(index_path.read_text(encoding="utf-8"))
row = next(iter(index.values()))
for field in (
"owner",
"hash",
"checksum_sha256",
"uploaded_at",
"created_at",
"last_accessed",
):
row.pop(field)
index_path.write_text(json.dumps(index), encoding="utf-8")
handler._index_cache = None
assert handler.cleanup_old_uploads(set(), set()) == 0
assert path.is_file()
assert json.loads(index_path.read_text(encoding="utf-8")) == index
def test_reservation_and_cleanup_are_serialized_without_dangling_references(
tmp_path,
monkeypatch,
):
# Writer wins: reservation holds the shared index lock, refreshes access,
# then cleanup observes the refreshed row and preserves the file.
writer_root = tmp_path / "writer-wins"
writer_root.mkdir()
writer_handler = _make_handler(writer_root)
upload_id = "2" * 32 + ".txt"
writer_path = _seed_old_uploads(writer_handler, [{
"id": upload_id,
"hash": "2" * 64,
"mime": "text/plain",
}])[upload_id]
write_entered = threading.Event()
release_write = threading.Event()
real_atomic_write = writer_handler._atomic_write_json
def blocking_reservation_write(path, data, *, sync_backup=False):
refreshed = any(
isinstance(row, dict) and row.get("last_accessed") != OLD_TIMESTAMP
for row in data.values()
)
if sync_backup and refreshed and not write_entered.is_set():
write_entered.set()
assert release_write.wait(5)
return real_atomic_write(path, data, sync_backup=sync_backup)
monkeypatch.setattr(writer_handler, "_atomic_write_json", blocking_reservation_write)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
reserve_future = pool.submit(
writer_handler.reserve_upload,
upload_id,
owner="alice",
)
assert write_entered.wait(5)
cleanup_future = pool.submit(writer_handler.cleanup_old_uploads, set(), set())
release_write.set()
assert reserve_future.result(timeout=5) is not None
assert cleanup_future.result(timeout=5) == 0
assert writer_path.is_file()
# Cleanup wins: reservation cannot pass the same lock until the row and
# bytes are gone, then fails so a caller cannot commit a dangling reference.
cleanup_root = tmp_path / "cleanup-wins"
cleanup_root.mkdir()
cleanup_handler = _make_handler(cleanup_root)
cleanup_path = _seed_old_uploads(cleanup_handler, [{
"id": upload_id,
"hash": "3" * 64,
"mime": "text/plain",
}])[upload_id]
remove_entered = threading.Event()
release_remove = threading.Event()
real_remove = os.remove
def blocking_remove(candidate):
if os.path.realpath(candidate) == os.path.realpath(cleanup_path):
remove_entered.set()
assert release_remove.wait(5)
return real_remove(candidate)
monkeypatch.setattr("src.upload_handler.os.remove", blocking_remove)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
cleanup_future = pool.submit(cleanup_handler.cleanup_old_uploads, set(), set())
assert remove_entered.wait(5)
reserve_future = pool.submit(
cleanup_handler.reserve_upload,
upload_id,
owner="alice",
)
release_remove.set()
assert cleanup_future.result(timeout=5) == 1
assert reserve_future.result(timeout=5) is None
assert not cleanup_path.exists()
def test_admin_cleanup_reference_discovery_failure_returns_503_without_deleting(
tmp_path,
monkeypatch,
):
from routes import upload_routes
handler = _make_handler(tmp_path)
upload_id = "d" * 32 + ".png"
path = _seed_old_uploads(handler, [
{"id": upload_id, "hash": "4" * 64, "mime": "image/png"},
])[upload_id]
def fail_reference_scan():
raise RuntimeError("database unavailable")
monkeypatch.setattr(
upload_routes,
"_collect_persisted_upload_references",
fail_reference_scan,
)
endpoint = _manual_cleanup_endpoint(handler, monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(_AdminRequest()))
assert exc.value.status_code == 503
assert path.is_file()
assert handler.get_upload_info(upload_id) is not None
def test_cleanup_restores_index_when_file_removal_fails(tmp_path, monkeypatch):
handler = _make_handler(tmp_path)
upload_id = "e" * 32 + ".txt"
path = _seed_old_uploads(handler, [
{
"id": upload_id,
"hash": "5" * 64,
"mime": "text/plain",
},
])[upload_id]
real_remove = os.remove
def fail_target_remove(candidate):
if os.path.realpath(candidate) == os.path.realpath(path):
raise PermissionError("file is in use")
return real_remove(candidate)
monkeypatch.setattr("src.upload_handler.os.remove", fail_target_remove)
assert handler.cleanup_old_uploads(set(), set()) == 0
assert path.is_file()
assert handler.get_upload_info(upload_id) is not None
assert any(
info["id"] == upload_id
for info in json.loads(
Path(handler.upload_dir, "uploads.json").read_text(encoding="utf-8")
).values()
)
assert any(
info["id"] == upload_id
for info in json.loads(
Path(handler.upload_dir, "uploads.json.bak").read_text(encoding="utf-8")
).values()
)
def test_admin_cleanup_with_corrupt_index_returns_503_and_fails_closed(
tmp_path,
monkeypatch,
):
from routes import upload_routes
handler = _make_handler(tmp_path)
upload_id = "9" * 32 + ".png"
path = _seed_old_uploads(handler, [
{"id": upload_id, "hash": "9" * 64, "mime": "image/png"},
])[upload_id]
Path(handler.upload_dir, "uploads.json").write_text(
'{"alice:broken": {',
encoding="utf-8",
)
handler._index_cache = None
monkeypatch.setattr(
upload_routes,
"_collect_persisted_upload_references",
lambda: (set(), set()),
)
endpoint = _manual_cleanup_endpoint(handler, monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(_AdminRequest()))
assert exc.value.status_code == 503
assert path.is_file()
def test_cleanup_with_missing_live_index_fails_closed(tmp_path):
handler = _make_handler(tmp_path)
upload_id = "8" * 32 + ".png"
dated_dir = Path(handler.upload_dir) / "2000" / "01" / "01"
dated_dir.mkdir(parents=True)
path = dated_dir / upload_id
path.write_bytes(b"unindexed bytes")
with pytest.raises(UploadCleanupSafetyError):
handler.cleanup_old_uploads(set(), set())
assert path.is_file()
def test_reference_discovery_covers_all_durable_upload_stores(
monkeypatch,
):
from routes import upload_routes
document_id = "f" * 32 + ".pdf"
version_id = "1" * 32 + ".pdf"
note_upload_id = "3" * 32 + ".png"
note_color_id = "2" * 32 + ".png"
calendar_upload_id = "4" * 32 + ".png"
event_upload_id = "5" * 32 + ".png"
event_description_id = "7" * 32 + ".txt"
event_location_id = "8" * 32 + ".png"
gallery_hash = "6" * 64
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
db = SessionLocal()
try:
db.add(DbSession(
id="session-2",
name="Reference sources",
endpoint_url="http://localhost",
model="test-model",
owner="alice",
))
db.add(Document(
id="document-1",
session_id="session-2",
title="PDF",
current_content=f'<!-- pdf_source upload_id="{document_id}" -->',
owner="alice",
))
db.add(DocumentVersion(
id="version-1",
document_id="document-1",
version_number=1,
content=f'<!-- pdf_form_source upload_id="{version_id}" fields="1" -->',
))
db.add(GalleryImage(
id="gallery-1",
# Gallery filenames are normally generated 12-hex names, so this
# record proves retention comes from its stored content hash.
filename="abcdef123456.png",
prompt="Chat upload",
owner="alice",
file_hash=gallery_hash,
))
db.add(Note(
id="note-1",
owner="alice",
title="Photo note",
image_url=f"/api/upload/{note_upload_id}",
color=f"odysseus://attachment/{note_color_id}",
))
db.add(CalendarCal(
id="calendar-1",
owner="alice",
name="Personal",
color=f"/api/upload/{calendar_upload_id}",
))
db.add(CalendarEvent(
uid="event-1",
calendar_id="calendar-1",
summary="Photo event",
dtstart=datetime(2026, 7, 10, 12, 0),
dtend=datetime(2026, 7, 10, 13, 0),
color=f"/api/upload/{event_upload_id}",
description=f"Notes: odysseus://attachment/{event_description_id}",
location=f"/api/upload/{event_location_id}",
))
db.commit()
finally:
db.close()
monkeypatch.setattr(upload_routes, "SessionLocal", SessionLocal)
try:
referenced_ids, referenced_hashes = (
upload_routes._collect_persisted_upload_references()
)
finally:
engine.dispose()
tmpfile.close()
try:
os.unlink(tmpfile.name)
except OSError:
pass
assert {
document_id,
version_id,
note_upload_id,
note_color_id,
calendar_upload_id,
event_upload_id,
event_description_id,
event_location_id,
} <= referenced_ids
assert gallery_hash in referenced_hashes
def test_write_reservation_extracts_only_explicit_internal_references():
upload_id = "a" * 32 + ".png"
checksum_like_text = "b" * 32
assert extract_internal_upload_ids(checksum_like_text) == set()
assert extract_internal_upload_ids(f"sha={checksum_like_text}") == set()
assert extract_internal_upload_ids({
"image": f"/api/upload/{upload_id}",
"nested": [f"odysseus://attachment/{upload_id}"],
}) == {upload_id}
assert extract_internal_upload_ids(
f'<!-- pdf_source upload_id="{upload_id}" -->'
) == {upload_id}
assert extract_internal_upload_ids(
f"[Attachment: photo.png | id={upload_id} | mime=image/png]"
) == {upload_id}
extensionless_id = "c" * 32
assert extract_internal_upload_ids(
f"See /api/upload/{extensionless_id}. Then continue."
) == {extensionless_id}
assert extract_internal_upload_ids(
f"Attachment: odysseus://attachment/{extensionless_id}: ready"
) == {extensionless_id}
assert extract_internal_upload_ids(f"/api/upload/{upload_id}/extra") == set()
def test_reservation_never_uses_admin_override(tmp_path):
handler = _make_handler(tmp_path)
upload_id = "c" * 32 + ".txt"
_seed_old_uploads(handler, [{
"id": upload_id,
"hash": "c" * 64,
"mime": "text/plain",
"owner": "alice",
}])
assert reserve_upload_references(
handler,
"alice",
f"odysseus://attachment/{upload_id}",
) is None
assert reserve_upload_references(
handler,
"admin",
f"odysseus://attachment/{upload_id}",
) == upload_id
assert handler.reserve_upload(
upload_id,
owner="admin",
auth_manager=_AdminAuth(),
allow_admin=False,
) is None
assert reserve_message_upload_references(
handler,
"admin",
"legacy attachment metadata",
{"attachments": [{"id": upload_id, "name": "owned.txt"}]},
) == upload_id
def test_remaining_durable_writers_reserve_before_commit(monkeypatch):
import core.database as database
import core.session_manager as session_manager_module
import src.database as legacy_database
from core.models import ChatMessage
from core.session_manager import SessionManager
from src import tool_utils
from src.agent_tools.document_tools import EditDocumentTool
from src.tools.calendar import do_manage_calendar
from src.tools.notes import do_manage_notes
upload_id = "6" * 32 + ".png"
class RejectingHandler:
def reserve_upload(self, _candidate, **_kwargs):
return None
handler = RejectingHandler()
monkeypatch.setattr(tool_utils, "_upload_handler", handler)
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
monkeypatch.setattr(database, "SessionLocal", SessionLocal)
monkeypatch.setattr(legacy_database, "SessionLocal", SessionLocal)
monkeypatch.setattr(legacy_database, "Document", Document, raising=False)
monkeypatch.setattr(
legacy_database,
"DocumentVersion",
DocumentVersion,
raising=False,
)
monkeypatch.setattr(session_manager_module, "SessionLocal", SessionLocal)
db = SessionLocal()
try:
db.add(DbSession(
id="writer-session",
name="Writer coverage",
endpoint_url="http://localhost",
model="test-model",
owner="alice",
))
db.add(Document(
id="email-document",
session_id="writer-session",
title="New Email",
language="email",
current_content="To: team@example.test\nSubject: Status\n---\nOld body",
version_count=1,
owner="alice",
))
db.commit()
manager = SessionManager()
manager.upload_handler = handler
manager._persist_message(
"writer-session",
ChatMessage(
"user",
"attachment",
metadata={"attachments": [{"id": upload_id}]},
),
)
document_result = asyncio.run(EditDocumentTool().execute(
"<<<FIND>>>\n\n<<<REPLACE>>>\n"
f"See /api/upload/{upload_id}\n<<<END>>>",
{"doc_id": "email-document", "owner": "alice"},
))
assert document_result["exit_code"] == 1
assert "no longer available" in document_result["error"]
calendar_result = asyncio.run(do_manage_calendar(
json.dumps({
"action": "create_event",
"summary": "Attachment review",
"dtstart": "2026-07-12T12:00:00",
"description": f"See /api/upload/{upload_id}",
}),
owner="alice",
))
assert calendar_result["exit_code"] == 1
assert "no longer available" in calendar_result["error"]
note_result = asyncio.run(do_manage_notes(
json.dumps({
"action": "add",
"title": "Attachment note",
"content": f"See /api/upload/{upload_id}",
}),
owner="alice",
))
assert note_result["exit_code"] == 1
assert "no longer available" in note_result["error"]
verify = SessionLocal()
try:
assert verify.query(DbChatMessage).count() == 0
stored_doc = verify.query(Document).filter(Document.id == "email-document").one()
assert stored_doc.current_content.endswith("Old body")
assert verify.query(CalendarEvent).count() == 0
assert verify.query(Note).count() == 0
finally:
verify.close()
finally:
db.close()
engine.dispose()
tmpfile.close()
try:
os.unlink(tmpfile.name)
except OSError:
pass
def test_note_calendar_and_document_routes_reserve_before_database_writes(monkeypatch):
from routes.calendar_routes import EventCreate, setup_calendar_routes
from routes import document_routes
from routes.document_helpers import DocumentCreate
from routes.note_routes import NoteCreate, setup_note_routes
from src import auth_helpers
upload_id = "d" * 32 + ".png"
class RejectingHandler:
def __init__(self):
self.calls = []
def reserve_upload(self, candidate, **kwargs):
self.calls.append((candidate, kwargs))
return None
request = SimpleNamespace(
state=SimpleNamespace(current_user="alice", api_token=False),
app=SimpleNamespace(state=SimpleNamespace()),
)
note_handler = RejectingHandler()
note_router = setup_note_routes(upload_handler=note_handler)
create_note = next(
route.endpoint for route in note_router.routes
if route.endpoint.__name__ == "create_note"
)
with pytest.raises(HTTPException) as note_error:
create_note(
request,
NoteCreate(image_url=f"/api/upload/{upload_id}"),
)
assert note_error.value.status_code == 409
assert note_handler.calls == [
(upload_id, {"owner": "alice", "allow_admin": False})
]
calendar_handler = RejectingHandler()
calendar_router = setup_calendar_routes(upload_handler=calendar_handler)
create_event = next(
route.endpoint for route in calendar_router.routes
if route.endpoint.__name__ == "create_event"
)
with pytest.raises(HTTPException) as calendar_error:
asyncio.run(create_event(
request,
EventCreate(
summary="Photo",
dtstart="2026-07-10T12:00:00",
color=f"odysseus://attachment/{upload_id}",
),
))
assert calendar_error.value.status_code == 409
assert calendar_handler.calls == [
(upload_id, {"owner": "alice", "allow_admin": False})
]
class EmptyDb:
@staticmethod
def close():
return None
document_handler = RejectingHandler()
monkeypatch.setattr(document_routes, "SessionLocal", EmptyDb)
monkeypatch.setattr(
auth_helpers,
"require_privilege",
lambda _request, _privilege: "alice",
)
document_router = document_routes.setup_document_routes(
SimpleNamespace(),
document_handler,
)
create_document = next(
route.endpoint for route in document_router.routes
if route.endpoint.__name__ == "create_document"
)
with pytest.raises(HTTPException) as document_error:
asyncio.run(create_document(
request,
DocumentCreate(
language="markdown",
content=f"![image](/api/upload/{upload_id})",
),
))
assert document_error.value.status_code == 409
assert document_handler.calls == [
(upload_id, {"owner": "alice", "allow_admin": False})
]
+27
View File
@@ -64,6 +64,33 @@ def test_strict_mode_blocks_private_and_loopback():
assert ok is False and "private" in reason
def test_strict_mode_blocks_cgnat_shared_space():
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
# A public redirect into it must be rejected under full SSRF lockdown,
# even though ipaddress reports is_private=False for this range.
CGNAT = _resolver({"svc.example": ["100.64.0.1"]})
ok, reason = check_outbound_url("http://svc.example:8080", block_private=True, resolver=CGNAT)
assert ok is False
assert "blocked" in reason
def test_strict_mode_blocks_non_global_ranges():
# Strict mode is a full SSRF lockdown: only globally-routable public
# addresses may be reached. Benchmarking (198.18.0.0/15) and TEST-NET
# documentation space (192.0.2.0/24) are not globally routable.
for ip in ("198.18.0.1", "192.0.2.10"):
res = _resolver({"svc.example": [ip]})
ok, reason = check_outbound_url("http://svc.example", block_private=True, resolver=res)
assert ok is False, ip
assert "blocked" in reason
def test_strict_mode_still_allows_public_ip():
# The lockdown must not reject a legitimate globally-routable target.
ok, reason = check_outbound_url("https://example.com/v1", block_private=True, resolver=PUBLIC)
assert ok is True, reason
def test_unresolvable_host_blocked():
ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC)
assert ok is False
+142 -2
View File
@@ -125,6 +125,67 @@ async def test_read_write_edit_confined_e2e(ws, admin):
assert not os.path.exists(escape)
@pytest.mark.asyncio
async def test_apply_patch_confined_e2e(ws, admin):
with open(os.path.join(ws, "patchme.txt"), "w") as f:
f.write("alpha\nbeta\ngamma\n")
patch = """*** Begin Patch
*** Update File: patchme.txt
@@
alpha
-beta
+BETA
gamma
*** Add File: added.txt
+new file
*** End Patch"""
_, r = await execute_tool_block(_block("apply_patch", patch), owner="a", workspace=ws)
assert r["exit_code"] == 0
assert r["diff"]["added"] >= 2
with open(os.path.join(ws, "patchme.txt")) as f:
assert f.read() == "alpha\nBETA\ngamma\n"
with open(os.path.join(ws, "added.txt")) as f:
assert f.read() == "new file\n"
outside = tempfile.mkdtemp()
outside_file = os.path.join(outside, "x.txt")
with open(outside_file, "w") as f:
f.write("x\n")
escape_patch = f"""*** Begin Patch
*** Update File: {outside_file}
@@
-x
+y
*** End Patch"""
_, r = await execute_tool_block(_block("apply_patch", escape_patch), owner="a", workspace=ws)
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
with open(outside_file) as f:
assert f.read() == "x\n"
@pytest.mark.asyncio
async def test_todowrite_persists_session_list(tmp_path, monkeypatch, admin):
import src.agent_tools.coding_tools as coding_tools
monkeypatch.setattr(coding_tools, "_TODO_DIR", str(tmp_path))
payload = {
"todos": [
{"content": "Inspect code", "status": "completed", "priority": "high"},
{"content": "Patch code", "status": "in_progress", "priority": "high"},
]
}
_, r = await execute_tool_block(
_block("todowrite", json.dumps(payload)),
session_id="chat/one",
owner="a",
workspace=str(tmp_path),
)
assert r["exit_code"] == 0
assert "[>] Patch code" in r["output"]
saved = json.load(open(tmp_path / "chat_one.json", encoding="utf-8"))
assert saved["todos"][1]["status"] == "in_progress"
@pytest.mark.asyncio
async def test_grep_and_ls_confined_e2e(ws, admin):
with open(os.path.join(ws, "doc.txt"), "w") as f:
@@ -231,7 +292,7 @@ async def test_binding_does_not_leak(ws, admin):
# must still surface the file tools, otherwise the agent says it has no file
# access (the bug this guards against).
def _sent_tool_names(monkeypatch, *, workspace):
def _sent_tool_names(monkeypatch, *, workspace, message="look at the local project", force_keyword_fallback=False):
import asyncio
import src.agent_loop as al
@@ -240,6 +301,13 @@ def _sent_tool_names(monkeypatch, *, workspace):
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
# Isolate the selection logic from owner gating (tested separately).
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
if force_keyword_fallback:
import src.tool_index as ti
def _raise_get_tool_index():
raise RuntimeError("skip vector retrieval")
monkeypatch.setattr(ti, "get_tool_index", _raise_get_tool_index, raising=False)
captured = []
@@ -253,7 +321,7 @@ def _sent_tool_names(monkeypatch, *, workspace):
async def _run():
gen = al.stream_agent_loop(
"https://api.openai.com/v1", "gpt-test",
[{"role": "user", "content": "look at the local project"}],
[{"role": "user", "content": message}],
max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace,
)
return [c async for c in gen]
@@ -276,12 +344,84 @@ def test_low_signal_with_workspace_surfaces_readonly_file_tools(monkeypatch):
assert "python" not in names
def test_workspace_coding_request_surfaces_edit_and_verify_tools(monkeypatch):
names = _sent_tool_names(
monkeypatch,
workspace="/tmp",
message="fix the failing frontend test in this repo",
force_keyword_fallback=True,
)
assert "get_workspace" in names
assert "read_file" in names
assert "grep" in names
assert "edit_file" in names
assert "write_file" in names
assert "apply_patch" in names
assert "todowrite" in names
assert "bash" in names
assert "python" in names
def test_low_signal_without_workspace_excludes_file_tools(monkeypatch):
names = _sent_tool_names(monkeypatch, workspace=None)
assert "read_file" not in names
assert "get_workspace" not in names
def test_explicit_workspace_request_without_workspace_stops(monkeypatch):
import asyncio
import src.agent_loop as al
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
async def _should_not_stream(*args, **kwargs):
raise AssertionError("LLM should not be called when explicit workspace is missing")
yield ""
monkeypatch.setattr(al, "stream_llm_with_fallback", _should_not_stream, raising=False)
async def _run():
gen = al.stream_agent_loop(
"https://api.openai.com/v1", "gpt-test",
[{"role": "user", "content": "In this workspace, fix a typo and verify it."}],
max_rounds=1, relevant_tools=None, owner="admin", workspace=None,
)
return [c async for c in gen]
chunks = asyncio.run(_run())
text = "".join(chunks)
assert "No active workspace is set" in text
assert "/workspace set /absolute/path" in text
assert '"missing_workspace": true' in text
def test_workspace_coding_mode_prompt_is_injected(monkeypatch):
import src.agent_loop as al
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
al._cached_base_prompt = None
al._cached_base_prompt_key = None
messages, _ = al._build_system_prompt(
messages=[{"role": "user", "content": "fix the bug"}],
model="gpt-test",
active_document=None,
mcp_mgr=None,
relevant_tools={"get_workspace", "read_file", "grep", "edit_file", "write_file", "apply_patch", "todowrite", "bash"},
workspace="/tmp/example-repo",
)
system_text = "\n\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
assert "## Workspace coding mode" in system_text
assert "Active workspace: `/tmp/example-repo`" in system_text
assert "call `todowrite`" in system_text
assert "Change repo files with `apply_patch`" in system_text
# ── browse route is admin-gated ─────────────────────────────────────────
def test_browse_is_admin_gated(monkeypatch):