1 Commits

Author SHA1 Message Date
Kenny Van de Maele 4371425514 refactor(tools): remove dead workspace-confinement plumbing
Commit e6b1009 removed the workspace feature's entry point (deleted
routes/workspace_routes.py + static/js/workspace.js and dropped the
workspace-param parsing in chat_routes), but left the downstream backend
plumbing dangling: chat_routes passed a hardcoded workspace=None into
stream_agent_loop, which forwarded it to execute_tool_block, so the
workspace value was permanently None and every workspace-gated branch
was unreachable.

Remove the now-dead code (no behavior change, since workspace was always
None):
- src/tool_execution.py: drop _resolve_tool_path_in_workspace and the
  workspace params/branches on execute_tool_block, _direct_fallback,
  _call_mcp_tool, _do_edit_file, and _resolve_search_root; restore the
  bash/python/bg cwd to _AGENT_WORKDIR.
- src/agent_loop.py: drop the workspace param on stream_agent_loop, the
  dead 'ACTIVE WORKSPACE' system-prompt block, and the workspace forward.
- routes/chat_routes.py: drop the hardcoded workspace=None arg and var.
- tests: delete test_workspace_confine.py (tested the removed feature) and
  the workspace assertion in test_tool_policy.py.

Full suite: 2903 passed, 1 skipped.
2026-06-09 08:27:07 +02:00
15 changed files with 14 additions and 652 deletions
-1
View File
@@ -472,7 +472,6 @@ components = initialize_managers(BASE_DIR, rag_manager)
session_manager = components["session_manager"]
from src.assistant_log import set_session_manager as _set_asst_sm
_set_asst_sm(session_manager)
app.state.session_manager = session_manager
memory_manager = components["memory_manager"]
memory_vector = components.get("memory_vector")
upload_handler = components["upload_handler"]
+5 -100
View File
@@ -7,13 +7,7 @@ import asyncio
import logging
import os
import json
import re
from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src.settings import (
@@ -297,17 +291,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
if new_username in auth_manager.users:
raise HTTPException(409, "Username already taken")
# Gate on auth first. Every mutation below is contingent on this
# succeeding — doing it last meant a rejected rename (e.g. reserved
# username) left file-backed owner fields already rewritten with no
# way to roll them back.
ok = auth_manager.rename_user(old_username, new_username, user)
if not ok:
raise HTTPException(400, "Cannot rename user")
# Usernames are ownership keys for user data. Rename the common
# owner-scoped DB rows so the account keeps access to its sessions,
# docs, email accounts, tasks, etc.
# owner-scoped DB rows before changing auth so the account keeps
# access to its sessions, docs, email accounts, tasks, etc.
try:
from sqlalchemy import func
from core.database import Base, SessionLocal
@@ -349,90 +335,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except Exception as e:
logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e)
# deep_research: each completed report is a standalone JSON file with
# an `owner` field. research_routes filters by d.get("owner") == user,
# so a stale owner makes every report invisible to the renamed user.
try:
dr_dir = Path(DEEP_RESEARCH_DIR)
if dr_dir.is_dir():
for p in dr_dir.glob("*.json"):
try:
d = json.loads(p.read_text(encoding="utf-8"))
if str(d.get("owner", "")).strip().lower() == old_username:
d["owner"] = new_username
atomic_write_json(str(p), d)
except Exception as err:
logger.warning("Failed to update research owner in %s: %s", p.name, err)
except Exception as e:
logger.warning("Failed to rename research owner references %s -> %s: %s", old_username, new_username, e)
# memory.json: a flat JSON array where each entry carries an `owner`
# field. memory_manager.load(owner=user) filters on it, so stale
# entries disappear from the memory panel.
try:
if os.path.isfile(MEMORY_FILE):
with open(MEMORY_FILE, encoding="utf-8") as fh:
entries = json.loads(fh.read())
if isinstance(entries, list):
changed = False
for entry in entries:
if isinstance(entry, dict) and str(entry.get("owner", "")).strip().lower() == old_username:
entry["owner"] = new_username
changed = True
if changed:
atomic_write_json(MEMORY_FILE, entries)
except Exception as e:
logger.warning("Failed to rename memory.json owner references %s -> %s: %s", old_username, new_username, e)
# skills: SKILL.md frontmatter carries owner: <username>; the usage
# sidecar (_usage.json) keys entries as owner::skill-name. Both must
# be updated or the renamed user's Skills panel goes empty.
try:
skills_root = Path(SKILLS_DIR)
if skills_root.is_dir():
_owner_re = re.compile(
r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$'
)
for p in skills_root.rglob("SKILL.md"):
try:
text = p.read_text(encoding="utf-8")
new_text = _owner_re.sub(r'\g<1>' + new_username, text)
if new_text != text:
atomic_write_text(str(p), new_text)
except Exception as err:
logger.warning("Failed to update skill owner in %s: %s", p, err)
usage_path = skills_root / "_usage.json"
if usage_path.is_file():
try:
usage = json.loads(usage_path.read_text(encoding="utf-8"))
if isinstance(usage, dict):
prefix = old_username + "::"
new_usage = {}
changed = False
for k, v in usage.items():
if k.startswith(prefix):
new_usage[new_username + "::" + k[len(prefix):]] = v
changed = True
else:
new_usage[k] = v
if changed:
atomic_write_json(str(usage_path), new_usage)
except Exception as err:
logger.warning("Failed to update skills usage keys %s -> %s: %s", old_username, new_username, err)
except Exception as e:
logger.warning("Failed to rename skills owner references %s -> %s: %s", old_username, new_username, e)
# The in-memory session cache (session_manager.sessions) stores each
# session's owner at load time. Without this patch the renamed user's
# sessions are invisible on the next /api/sessions call because
# get_sessions_for_user does an exact `s.owner == username` comparison
# against stale in-memory values.
sm = getattr(request.app.state, "session_manager", None)
if sm is not None:
for sess in list(getattr(sm, "sessions", {}).values()):
if str(getattr(sess, "owner", None) or "").strip().lower() == old_username:
sess.owner = new_username
ok = auth_manager.rename_user(old_username, new_username, user)
if not ok:
raise HTTPException(400, "Cannot rename user")
# The owner-rename loop above updated ApiToken.owner in the DB, but the
# bearer-token cache still maps each token to the OLD owner. Without
# refreshing it, the renamed user's API tokens resolve to the old (now
+3 -11
View File
@@ -283,7 +283,6 @@ _HOST_TO_CURATED = (
("fireworks.ai", "fireworks"),
("googleapis.com", "google"),
("x.ai", "xai"),
("nvidia.com", "nvidia"),
("openrouter.ai", "openrouter"),
("ollama.com", "ollama"),
)
@@ -478,17 +477,10 @@ _NON_CHAT_PREFIXES = (
"dall-e", "tts-", "whisper", "text-embedding", "embedding",
"davinci", "babbage", "moderation", "omni-moderation",
"sora", "gpt-image", "chatgpt-image",
# embedding / retrieval / non-chat models (common across providers)
"snowflake/arctic-embed", "nvidia/nv-embed", "embed",
)
_NON_CHAT_CONTAINS = (
"-realtime", "-transcribe", "-tts", "-codex",
"codex-", "content-safety", "-safety", "-reward", "nvclip",
"kosmos", "fuyu", "deplot", "vila", "neva",
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
"topic-control", "calibration",
"ai-synthetic-video", "cosmos-reason2",
"bge", "llama-guard",
"codex-",
)
_NON_CHAT_EXACT_PREFIXES = (
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
@@ -739,7 +731,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
for _e in _PROVIDER_CURATED.get(_ck, []):
if _e not in set(models) and not any(m.startswith(_e) for m in models):
models.append(_e)
return [m for m in models if _is_chat_model(m)]
return models
except httpx.HTTPStatusError as e:
if api_key:
status = e.response.status_code if e.response is not None else "unknown"
@@ -763,7 +755,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
data = r.json()
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
if models:
return [m for m in models if _is_chat_model(m)]
return models
except Exception as e:
logger.debug(f"Ollama /api/tags probe failed for {base}: {e}")
# Fall back to curated list if the provider has a URL-based match (e.g. z.ai has no /models endpoint)
+3 -5
View File
@@ -855,7 +855,7 @@ def _build_system_prompt(
_ov_sig = _hl.sha256(_json.dumps(get_builtin_overrides() or {}, sort_keys=True).encode()).hexdigest()
except Exception:
_ov_sig = ""
cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context)
cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, suppress_local_context)
if _cached_base_prompt and _cached_base_prompt_key == cache_key and not active_document:
agent_prompt = _cached_base_prompt
# Skill index is user-editable (name + description), so it must never
@@ -863,7 +863,7 @@ def _build_system_prompt(
# when the cache hits.
_, _skill_index_block = _build_base_prompt(
disabled_tools, mcp_mgr, needs_admin, relevant_tools,
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
mcp_disabled_map=mcp_disabled_map, compact=compact,
suppress_local_context=suppress_local_context,
)
else:
@@ -874,7 +874,6 @@ def _build_system_prompt(
relevant_tools,
mcp_disabled_map=mcp_disabled_map,
compact=compact,
owner=owner,
suppress_local_context=suppress_local_context,
)
if not active_document:
@@ -1247,7 +1246,6 @@ def _build_base_prompt(
relevant_tools=None,
mcp_disabled_map=None,
compact: bool = False,
owner: Optional[str] = None,
suppress_local_context: bool = False,
):
"""Build the agent prompt with only relevant tools included.
@@ -1301,7 +1299,7 @@ def _build_base_prompt(
from src.constants import DATA_DIR
_sm = SkillsManager(DATA_DIR)
active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or []))
skill_idx = _sm.index_for(owner=owner, active_toolsets=active_tools)
skill_idx = _sm.index_for(owner=None, active_toolsets=active_tools)
if skill_idx:
lines = ["## Available skills",
"Procedures the assistant should consult before doing domain work. "
+2 -11
View File
@@ -196,22 +196,13 @@ def _get_or_reset_collection(chroma_client, name: str, metadata: Dict[str, Any],
try:
chroma_client.delete_collection(name)
restored = chroma_client.get_or_create_collection(name=name, metadata=current)
# chromadb returns embeddings as a numpy ndarray, whose truth value
# is ambiguous — `preserved.get("embeddings") or []` and a bare
# `if ... and old_embeddings:` both raise ValueError, which aborts
# the restore and loses the rows the reset was supposed to keep.
# Use explicit None/len checks instead.
old_embeddings = preserved.get("embeddings")
if old_embeddings is None:
old_embeddings = []
if ids and docs and len(old_embeddings):
old_embeddings = preserved.get("embeddings") or []
if ids and docs and old_embeddings:
for start in range(0, len(ids), 100):
batch_ids = ids[start:start + 100]
batch_docs = docs[start:start + 100]
batch_metas = metas[start:start + 100]
batch_embeddings = old_embeddings[start:start + 100]
if hasattr(batch_embeddings, "tolist"):
batch_embeddings = batch_embeddings.tolist()
if len(batch_metas) < len(batch_ids):
batch_metas += [{}] * (len(batch_ids) - len(batch_metas))
restored.add(
-3
View File
@@ -444,8 +444,6 @@ def _detect_provider(url: str) -> str:
return "openrouter"
if _host_match(url, "groq.com"):
return "groq"
if _host_match(url, "nvidia.com"):
return "nvidia"
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(url):
return "chatgpt-subscription"
@@ -491,7 +489,6 @@ def _provider_label(url: str) -> str:
if is_copilot_base(url): return "GitHub Copilot"
if _host_match(url, "mistral.ai"): return "Mistral"
if _host_match(url, "deepseek.com"): return "DeepSeek"
if _host_match(url, "nvidia.com"): return "NVIDIA"
if _host_match(url, "googleapis.com"): return "Google"
if _host_match(url, "together.xyz", "together.ai"): return "Together"
if _host_match(url, "fireworks.ai"): return "Fireworks"
-1
View File
@@ -2095,7 +2095,6 @@
<option value="https://opencode.ai/zen/v1" data-logo="opencode">OpenCode Zen</option>
<option value="https://opencode.ai/zen/go/v1" data-logo="opencode">OpenCode Go</option>
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
<option value="https://integrate.api.nvidia.com/v1" data-logo="nvidia">NVIDIA</option>
</select>
<!-- API key row stays in DOM, hidden until Key button is
clicked. Mirrors the Local section pattern: most users
-1
View File
@@ -118,7 +118,6 @@ const _ENDPOINT_LABELS = [
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)nvidia\.com$/i, "NVIDIA"],
[/(^|\.)x\.ai$/i, "xAI"],
];
+1 -5
View File
@@ -43,7 +43,6 @@ const PROVIDER_PATTERNS = [
{ re: /^gsk_/, name: 'Groq', url: 'https://api.groq.com/openai/v1' },
{ re: /^AIza/, name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
{ re: /^xai-/, name: 'xAI', url: 'https://api.x.ai/v1' },
{ re: /^nvapi-/, name: 'NVIDIA', url: 'https://integrate.api.nvidia.com/v1' },
];
const SETUP_PROVIDER_URLS = {
deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
@@ -57,9 +56,8 @@ const SETUP_PROVIDER_URLS = {
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
'opencode-zen': { name: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1' },
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
nvidia: { name: 'NVIDIA', url: 'https://integrate.api.nvidia.com/v1' },
};
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go', 'nvidia'];
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
const SETUP_DEVICE_AUTH_PROVIDERS = [
{ key: 'copilot', name: 'GitHub Copilot', aliases: ['github'], command: '/setup copilot' },
{ key: 'chatgpt-subscription', name: 'ChatGPT Subscription', aliases: ['chatgptsubscription', 'chatgpt-sub', 'codex'], command: '/setup chatgpt-subscription' },
@@ -99,7 +97,6 @@ function _setupProviderFromInput(input) {
google: 'gemini',
xai: 'xai',
grok: 'xai',
nvidia: 'nvidia',
};
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
}
@@ -127,7 +124,6 @@ function _extractSetupProviderCredential(input) {
['groq', 'groq'],
['google', 'gemini'], ['gemini', 'gemini'],
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
['nvidia', 'nvidia'],
];
for (const [alias, key] of providerAliases) {
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
@@ -1,68 +0,0 @@
"""Embedding-lane reset must restore rows even when chromadb returns the
preserved embeddings as a numpy ndarray.
Real chromadb returns collection.get(include=["embeddings"]) as a numpy
ndarray. The restore-after-failed-rewrite path used `embeddings or []` and a
bare `if ... and embeddings:`, both of which raise
"truth value of an array ... is ambiguous" on an ndarray aborting the
restore and wiping the collection the reset was meant to preserve.
This mirrors test_lane_reset_restores_existing_collection_when_rewrite_fails
in test_embedding_lanes.py, but the preserved embeddings come back as ndarray.
"""
import numpy as np
from src.embedding_lanes import build_embedding_lanes
from tests.test_embedding_lanes import FakeChroma, FakeEmbedder, _patch_chroma
def test_lane_reset_restores_when_chroma_returns_numpy_embeddings(monkeypatch):
fake = FakeChroma()
old_custom = fake.get_or_create_collection(
"odysseus_memories_custom",
metadata={
"embedding_lane": "custom",
"embedding_dimension": 384,
"embedding_fingerprint": "old",
},
)
old_custom.add(
ids=["existing-memory"],
embeddings=[[0.0] * 384],
documents=["existing custom memory"],
metadatas=[{"source": "memory"}],
)
# Make the preserved embeddings come back as a numpy ndarray, like real
# chromadb does.
real_get = old_custom.get
def ndarray_get(*args, **kwargs):
result = real_get(*args, **kwargs)
result["embeddings"] = np.array(result["embeddings"])
return result
old_custom.get = ndarray_get
# Force the post-reset rewrite to fail so the restore branch runs.
fake.fail_next_add_for["odysseus_memories_custom"] = 1
_patch_chroma(monkeypatch, fake)
import src.embedding_lanes as lanes
monkeypatch.setattr(lanes, "_build_custom_client", lambda: FakeEmbedder(768, "nomic", "http://embeddings/v1"))
def fail_fastembed():
raise RuntimeError("fastembed missing")
monkeypatch.setattr(lanes, "_build_fastembed_client", fail_fastembed)
built = build_embedding_lanes("odysseus_memories")
# Both lanes are unavailable, but the existing row must survive — not be
# wiped by an ndarray-truthiness crash in the restore path.
assert built == []
restored = fake.collections["odysseus_memories_custom"]
assert restored.count() == 1
assert restored.get()["ids"] == ["existing-memory"]
assert len(restored.rows["existing-memory"]["embedding"]) == 384
-2
View File
@@ -347,8 +347,6 @@ class TestIsChatModel:
"gpt-4o", "gpt-4o-mini", "claude-sonnet-4", "llama-3.3-70b",
"deepseek-chat", "gemini-2.0-flash", "o3",
"llama-4-scout-17b-16e-instruct",
"gemma-2b-it", "google/gemma-2b-it",
"bigcode/starcoder2-15b-instruct",
])
def test_chat_models(self, model_id):
assert _is_chat_model(model_id) is True
-2
View File
@@ -40,7 +40,6 @@ class TestDetectProvider:
("https://anthropic.com/v1", "anthropic"),
("https://openrouter.ai/api/v1", "openrouter"),
("https://api.groq.com/openai/v1", "groq"),
("https://integrate.api.nvidia.com/v1", "nvidia"),
("http://localhost:11434/api", "ollama"),
("https://ollama.com", "ollama"),
# xAI, DeepSeek and Gemini's OpenAI-compatible surface are NOT
@@ -85,7 +84,6 @@ class TestProviderLabel:
("https://api.openai.com/v1", "OpenAI"),
("https://openrouter.ai/api/v1", "OpenRouter"),
("https://api.groq.com/openai/v1", "Groq"),
("https://integrate.api.nvidia.com/v1", "NVIDIA"),
("https://api.mistral.ai/v1", "Mistral"),
("https://api.deepseek.com", "DeepSeek"),
("https://generativelanguage.googleapis.com/v1beta/openai", "Google"),
-4
View File
@@ -50,9 +50,6 @@ PROVIDER_CASES = [
("groq", "https://api.groq.com/openai/v1",
"https://api.groq.com/openai/v1/chat/completions",
"https://api.groq.com/openai/v1/models"),
("nvidia", "https://integrate.api.nvidia.com/v1",
"https://integrate.api.nvidia.com/v1/chat/completions",
"https://integrate.api.nvidia.com/v1/models"),
("xai", "https://api.x.ai/v1",
"https://api.x.ai/v1/chat/completions",
"https://api.x.ai/v1/models"),
@@ -115,7 +112,6 @@ def test_headers_anthropic_without_key_still_sends_version():
"https://api.x.ai/v1",
"https://api.deepseek.com",
"https://api.groq.com/openai/v1",
"https://integrate.api.nvidia.com/v1",
"https://generativelanguage.googleapis.com/v1beta/openai",
])
def test_headers_openai_style_use_bearer(base):
-384
View File
@@ -1,384 +0,0 @@
"""Renaming a user must update all three owner caches, not just the SQL DB.
The DB owner-rename loop in the rename_user route updates every SQL-backed
owner column, but three file-backed / in-memory stores are left stale:
1. session_manager.sessions in-memory session objects carry s.owner set at
load time; get_sessions_for_user does an exact `s.owner == username` check,
so the renamed user's sidebar empties until a server restart.
2. data/deep_research/*.json each report JSON has an `owner` field;
research_routes filters by `d.get("owner") == user`, making every report
invisible after rename.
3. data/memory.json a flat array where every entry has an `owner` field;
memory_manager.load(owner=user) filters on it, so all memories vanish.
Regression coverage: these bugs are invisible in unit tests that mock the DB
loop but don't exercise the file/cache patches added to the route.
"""
import asyncio
import json
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
def _route(router, name):
for r in router.routes:
if getattr(getattr(r, "endpoint", None), "__name__", "") == name:
return r.endpoint
raise AssertionError(name)
@pytest.fixture
def rename_endpoint(monkeypatch, tmp_path):
import routes.auth_routes as ar
import core.database as cdb
# Neutralize the DB owner-rename loop.
monkeypatch.setattr(cdb, "SessionLocal", lambda: MagicMock())
monkeypatch.setattr(cdb, "Base", SimpleNamespace(registry=SimpleNamespace(mappers=[])), raising=False)
# Neutralize the JSON-prefs rename.
pr = types.ModuleType("routes.prefs_routes")
pr._load = lambda: {}
pr._save = lambda d: None
monkeypatch.setitem(sys.modules, "routes.prefs_routes", pr)
# Patch the module-level constants so file-update steps write to tmp_path.
# (Patching sc.DATA_DIR wouldn't work — auth_routes binds DEEP_RESEARCH_DIR
# and MEMORY_FILE at import time, so we must patch those names on the module.)
monkeypatch.setattr(ar, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
monkeypatch.setattr(ar, "MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setattr(ar, "SKILLS_DIR", str(tmp_path / "skills"))
am = MagicMock()
am.is_admin.return_value = True
am.get_username_for_token.return_value = "admin"
am.users = {"alice": {}}
am.rename_user.return_value = True
return _route(ar.setup_auth_routes(am), "rename_user"), am, tmp_path
def _request(tmp_path, session_manager=None):
state = SimpleNamespace(
invalidate_token_cache=lambda: None,
session_manager=session_manager,
)
return SimpleNamespace(
cookies={"odysseus_session": "t"},
app=SimpleNamespace(state=state),
state=SimpleNamespace(current_user="admin"),
)
# ---------------------------------------------------------------------------
# 1. In-memory session cache
# ---------------------------------------------------------------------------
def test_rename_updates_in_memory_session_owner(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
# Build a fake session_manager with one session owned by alice.
sess = SimpleNamespace(owner="alice")
sm = SimpleNamespace(sessions={"s1": sess})
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path, sm)))
assert sess.owner == "alice2", "in-memory session owner was not updated on rename"
def test_rename_session_owner_case_insensitive(rename_endpoint):
"""Stored owner 'Alice' (mixed case) must match rename of 'alice'."""
endpoint, _am, tmp_path = rename_endpoint
sess = SimpleNamespace(owner="Alice")
sm = SimpleNamespace(sessions={"s1": sess})
asyncio.run(endpoint("alice", SimpleNamespace(username="bob"), _request(tmp_path, sm)))
assert sess.owner == "bob"
def test_rename_leaves_other_sessions_untouched(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
sess_alice = SimpleNamespace(owner="alice")
sess_other = SimpleNamespace(owner="carol")
sm = SimpleNamespace(sessions={"s1": sess_alice, "s2": sess_other})
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path, sm)))
assert sess_alice.owner == "alice2"
assert sess_other.owner == "carol", "unrelated session owner was modified"
def test_rename_no_session_manager_does_not_crash(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
# app.state without a session_manager must not raise.
req = SimpleNamespace(
cookies={"odysseus_session": "t"},
app=SimpleNamespace(state=SimpleNamespace(invalidate_token_cache=lambda: None)),
state=SimpleNamespace(current_user="admin"),
)
res = asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), req))
assert res["ok"] is True
# ---------------------------------------------------------------------------
# 2. deep_research JSON files
# ---------------------------------------------------------------------------
def test_rename_updates_research_json_owner(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
dr_dir = tmp_path / "deep_research"
dr_dir.mkdir()
report = {"query": "test", "owner": "alice", "status": "done"}
p = dr_dir / "abc123.json"
p.write_text(json.dumps(report), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
updated = json.loads(p.read_text(encoding="utf-8"))
assert updated["owner"] == "alice2", "deep_research JSON owner was not updated on rename"
def test_rename_research_json_case_insensitive(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
dr_dir = tmp_path / "deep_research"
dr_dir.mkdir()
p = (dr_dir / "r1.json")
p.write_text(json.dumps({"owner": "Alice"}), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="bob"), _request(tmp_path)))
assert json.loads(p.read_text())["owner"] == "bob"
def test_rename_leaves_other_research_untouched(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
dr_dir = tmp_path / "deep_research"
dr_dir.mkdir()
p_alice = dr_dir / "a.json"
p_carol = dr_dir / "c.json"
p_alice.write_text(json.dumps({"owner": "alice"}), encoding="utf-8")
p_carol.write_text(json.dumps({"owner": "carol"}), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert json.loads(p_alice.read_text())["owner"] == "alice2"
assert json.loads(p_carol.read_text())["owner"] == "carol"
def test_rename_no_deep_research_dir_does_not_crash(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
# No deep_research dir — must not crash.
res = asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert res["ok"] is True
def test_rename_research_respects_custom_data_dir(monkeypatch, tmp_path):
"""DEEP_RESEARCH_DIR (which honours ODYSSEUS_DATA_DIR) is used, not a
hardcoded relative path. Before the fix, setting ODYSSEUS_DATA_DIR made
the rename silently patch a different directory from where research files
actually live, so reports still disappeared after rename."""
import routes.auth_routes as ar
import core.database as cdb
custom_dr = tmp_path / "custom_data" / "deep_research"
custom_dr.mkdir(parents=True)
p = custom_dr / "rp-abc.json"
p.write_text(json.dumps({"query": "q", "owner": "alice", "status": "done"}), encoding="utf-8")
monkeypatch.setattr(cdb, "SessionLocal", lambda: MagicMock())
monkeypatch.setattr(cdb, "Base", SimpleNamespace(registry=SimpleNamespace(mappers=[])), raising=False)
pr = types.ModuleType("routes.prefs_routes")
pr._load = lambda: {}
pr._save = lambda d: None
monkeypatch.setitem(sys.modules, "routes.prefs_routes", pr)
monkeypatch.setattr(ar, "DEEP_RESEARCH_DIR", str(custom_dr))
monkeypatch.setattr(ar, "MEMORY_FILE", str(tmp_path / "memory.json"))
am = MagicMock()
am.is_admin.return_value = True
am.get_username_for_token.return_value = "admin"
am.users = {"alice": {}}
am.rename_user.return_value = True
endpoint = _route(ar.setup_auth_routes(am), "rename_user")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert json.loads(p.read_text(encoding="utf-8"))["owner"] == "alice2", (
"research JSON at custom DATA_DIR was not patched — DEEP_RESEARCH_DIR constant not used"
)
# ---------------------------------------------------------------------------
# 3. memory.json
# ---------------------------------------------------------------------------
def test_rename_updates_memory_json_owner(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
entries = [
{"id": "1", "text": "Lives in Berlin", "owner": "alice"},
{"id": "2", "text": "Likes Python", "owner": "carol"},
]
(tmp_path / "memory.json").write_text(json.dumps(entries), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
updated = json.loads((tmp_path / "memory.json").read_text(encoding="utf-8"))
assert updated[0]["owner"] == "alice2", "memory.json entry owner was not updated on rename"
assert updated[1]["owner"] == "carol", "unrelated memory entry was modified"
def test_rename_memory_json_case_insensitive(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
entries = [{"id": "1", "text": "x", "owner": "Alice"}]
(tmp_path / "memory.json").write_text(json.dumps(entries), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="bob"), _request(tmp_path)))
assert json.loads((tmp_path / "memory.json").read_text())[0]["owner"] == "bob"
def test_rename_no_memory_json_does_not_crash(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
# No memory.json — must not crash.
res = asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert res["ok"] is True
# ---------------------------------------------------------------------------
# 4. Skills (SKILL.md frontmatter + _usage.json sidecar)
# ---------------------------------------------------------------------------
_SKILL_MD = """\
---
name: test-skill
description: A test skill.
version: 1.0.0
category: general
status: published
confidence: 0.9
source: learned
owner: {owner}
---
## When to Use
When testing.
"""
def test_rename_updates_skill_md_owner(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
skill_dir = tmp_path / "skills" / "general" / "test-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(_SKILL_MD.format(owner="alice"), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
assert "owner: alice2" in content
assert "owner: alice\n" not in content
def test_rename_leaves_other_skill_owners_untouched(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
for owner, name in [("alice", "alice-skill"), ("carol", "carol-skill")]:
d = tmp_path / "skills" / "general" / name
d.mkdir(parents=True)
(d / "SKILL.md").write_text(_SKILL_MD.format(owner=owner).replace("test-skill", name), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert "owner: alice2" in (tmp_path / "skills" / "general" / "alice-skill" / "SKILL.md").read_text()
assert "owner: carol" in (tmp_path / "skills" / "general" / "carol-skill" / "SKILL.md").read_text()
def test_rename_updates_usage_sidecar_keys(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
skills_root = tmp_path / "skills"
skills_root.mkdir(parents=True)
usage = {
"alice::test-skill": {"uses": 3, "last_used": 1000},
"carol::other-skill": {"uses": 1, "last_used": 500},
"unscoped-skill": {"uses": 2, "last_used": 200},
}
(skills_root / "_usage.json").write_text(json.dumps(usage), encoding="utf-8")
asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
updated = json.loads((skills_root / "_usage.json").read_text(encoding="utf-8"))
assert "alice2::test-skill" in updated
assert "alice::test-skill" not in updated
assert "carol::other-skill" in updated
assert "unscoped-skill" in updated
def test_rename_no_skills_dir_does_not_crash(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
res = asyncio.run(endpoint("alice", SimpleNamespace(username="alice2"), _request(tmp_path)))
assert res["ok"] is True
# ---------------------------------------------------------------------------
# 5. P1 regression: rejected auth rename must not mutate file-backed stores
# ---------------------------------------------------------------------------
def test_rejected_rename_does_not_mutate_files(monkeypatch, tmp_path):
"""If auth_manager.rename_user() returns False, no file-backed store
should be touched. Before the fix the deep_research and memory writes
ran before the auth check, so a rejected rename (e.g. reserved username)
silently moved owner fields to the new name."""
import routes.auth_routes as ar
import core.database as cdb
monkeypatch.setattr(cdb, "SessionLocal", lambda: MagicMock())
monkeypatch.setattr(cdb, "Base", SimpleNamespace(registry=SimpleNamespace(mappers=[])), raising=False)
pr = types.ModuleType("routes.prefs_routes")
pr._load = lambda: {}
pr._save = lambda d: None
monkeypatch.setitem(sys.modules, "routes.prefs_routes", pr)
monkeypatch.setattr(ar, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
monkeypatch.setattr(ar, "MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setattr(ar, "SKILLS_DIR", str(tmp_path / "skills"))
# Seed files for alice.
dr = tmp_path / "deep_research"
dr.mkdir()
rp = dr / "rp-abc.json"
rp.write_text(json.dumps({"owner": "alice", "query": "q"}), encoding="utf-8")
mem = tmp_path / "memory.json"
mem.write_text(json.dumps([{"owner": "alice", "text": "x"}]), encoding="utf-8")
skill_dir = tmp_path / "skills" / "general" / "s"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(_SKILL_MD.format(owner="alice"), encoding="utf-8")
# Auth rejects the rename (reserved name, race, etc.).
am = MagicMock()
am.is_admin.return_value = True
am.get_username_for_token.return_value = "admin"
am.users = {"alice": {}}
am.rename_user.return_value = False
endpoint = _route(ar.setup_auth_routes(am), "rename_user")
with pytest.raises(Exception):
asyncio.run(endpoint("alice", SimpleNamespace(username="api"), _request(tmp_path)))
assert json.loads(rp.read_text())["owner"] == "alice", "research owner mutated after rejected rename"
assert json.loads(mem.read_text())[0]["owner"] == "alice", "memory owner mutated after rejected rename"
assert "owner: alice" in (skill_dir / "SKILL.md").read_text(), "skill owner mutated after rejected rename"
@@ -76,23 +76,6 @@ def _seed_index_skill(tmp_path: Path) -> Path:
return data_dir
def _write_index_skill(data_dir: Path, name: str, description: str, owner: str) -> None:
skill_dir = data_dir / "skills" / owner / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
f"name: {name}\n"
f"description: {description}\n"
"when_to_use: when this owner needs a private workflow\n"
"category: private\n"
"status: published\n"
f"owner: {owner}\n"
"---\n\n"
f"# {name}\n",
encoding="utf-8",
)
def _patch_prefs(monkeypatch, data_dir):
"""Mirror the helpers from test_skill_prompt_injection.py: point
`src.constants.DATA_DIR` at our tmp, and patch the prefs loader so
@@ -169,40 +152,3 @@ def test_skill_index_lands_in_untrusted_user_message(tmp_path, monkeypatch):
)
assert untrusted[0]["role"] == "user"
assert "Source: skills" in untrusted[0]["content"]
def test_skill_index_is_owner_scoped_across_prompt_cache_hits(tmp_path, monkeypatch):
"""Authenticated users must not receive another user's skill index.
This calls the prompt builder twice without clearing the base-prompt cache,
so the second call exercises the cache-hit path as well as owner scoping.
"""
data_dir = tmp_path / "data"
_write_index_skill(data_dir, "alice-only", "Alice private procedure", "alice")
_write_index_skill(data_dir, "bob-only", "Bob private procedure", "bob")
_patch_prefs(monkeypatch, data_dir)
from src.agent_loop import _build_system_prompt # noqa: WPS433
messages = [{"role": "user", "content": "use my workflow"}]
alice_out, _ = _build_system_prompt(
messages=messages, model="test-model",
active_document=None, mcp_mgr=None, owner="alice",
)
bob_out, _ = _build_system_prompt(
messages=messages, model="test-model",
active_document=None, mcp_mgr=None, owner="bob",
)
alice_text = "\n".join(m.get("content", "") or "" for m in alice_out)
bob_text = "\n".join(m.get("content", "") or "" for m in bob_out)
assert "alice-only" in alice_text
assert "Alice private procedure" in alice_text
assert "bob-only" not in alice_text
assert "Bob private procedure" not in alice_text
assert "bob-only" in bob_text
assert "Bob private procedure" in bob_text
assert "alice-only" not in bob_text
assert "Alice private procedure" not in bob_text