Merge remote-tracking branch 'origin/dev' into fix/fenced-tool-inline-args-email-dispatch

This commit is contained in:
botinate
2026-06-11 19:36:15 +02:00
93 changed files with 2952 additions and 203 deletions
+202
View File
@@ -0,0 +1,202 @@
# Test Layout Inventory
## Purpose
Inventory for the first low-risk split of the flat `tests/` directory
(issue #3712, parent #2523). This document only records *what* should move
first and *why*; it moves nothing. The actual move is a separate, mechanical
PR that relocates the listed files verbatim and changes no test content.
The target layout and category definitions come from
[`TESTING_STANDARD.md`](./TESTING_STANDARD.md); the collection-time markers
come from [`_taxonomy.py`](./_taxonomy.py), which classifies by **filename
tokens only** (paths are ignored, except the `tests/helpers/` rule). A file
keeps its `area_*`/`sub_*` markers when moved into a subdirectory, and
`conftest.py` discovers marker names recursively (`rglob`), so a move does not
disturb marker registration or focused selection.
## Current low-risk candidate groups
Groups whose tests need no route/app setup and no real DB/session setup:
1. **CLI / script tests** (`area_cli`, 28 files) - load `scripts/` entry
points via `tests.helpers.cli_loader.load_script`; DB access is stubbed
with `tests.helpers.db_stubs` (`SessionLocal` is a plain stub attribute).
No `TestClient`, no FastAPI app import, no SQLite files.
2. **Helper self-tests** (`area_helpers`) - e.g. `test_helpers_import_state.py`,
`test_db_stubs_helper.py`. Safe but tiny (two files), and they test the
shared helpers from the #3685 audit (merged) that the rest of the suite
depends on; little payoff as a first slice.
3. **Pure unit / parsing tests** (`area_unit`) - `*_nonstring.py`,
`*_nondict.py`, parsing tests. Large and heterogeneous; some touch
provider/session modules, so the boundary is less crisp.
4. **Static checks** - e.g. `test_readme_ascii_fenced.py`,
`test_docs_no_orphan_images.py`. Safe but tiny and `uncategorized` in the
taxonomy, so a move buys little and matches no existing marker.
Not candidates for the first move (per #3712 guidance): security/owner-scope
tests, route/API tests, DB/session-heavy tests, auth/session concurrency
tests, and the taxonomy/runner infrastructure tests that changed recently
(#3491, #3556, #3659, #3711).
## Recommended first move
**CLI / script tests → `tests/cli/`**
Why this group over the alternatives:
- Lowest coupling: every file imports only the script under test (via
`cli_loader`) plus `tests.helpers` stubs - no app, no routes, no real DB.
- Crisp, machine-checkable boundary: the set is exactly the files classified
`area_cli` by `_taxonomy.py`, so before/after selection counts can be
compared mechanically.
- Already the planned target dir for this category in `TESTING_STANDARD.md`
(`tests/cli/`).
- Absolute imports (`from tests.helpers...`) and unique basenames mean no
import-order or module-name collisions after the move.
- Lower risk than helper self-tests (tiny group, little payoff), unit tests
(fuzzy boundary), or anything security/route/session-shaped.
## Files included in the first move
The 28 files classified `area_cli` (verified against `_taxonomy.py`):
Note: this inventory was refreshed against current `dev` after `tests/test_research_cli_status.py` was added to the `area_cli` set.
- `tests/test_calendar_cli_name.py`
- `tests/test_contacts_cli_rows.py`
- `tests/test_cookbook_cli_state.py`
- `tests/test_docs_cli_content_length.py`
- `tests/test_gallery_cli_album_count.py`
- `tests/test_gallery_cli_preview.py`
- `tests/test_logs_cli_resolve_nonstring.py`
- `tests/test_mail_cli_read_empty_fetch.py`
- `tests/test_mail_cli_recipients.py`
- `tests/test_mcp_cli_env_serialize.py`
- `tests/test_mcp_cli_json.py`
- `tests/test_memory_cli_rows.py`
- `tests/test_notes_cli_items.py`
- `tests/test_personal_cli_rows.py`
- `tests/test_preset_cli_invalid_entries.py`
- `tests/test_preset_cli_set_corrupt_entry.py`
- `tests/test_preset_cli_store.py`
- `tests/test_research_cli_preview.py`
- `tests/test_research_cli_status_filter.py`
- `tests/test_research_cli_status.py`
- `tests/test_research_cli_store.py`
- `tests/test_sessions_cli.py`
- `tests/test_signature_cli_export.py`
- `tests/test_skills_cli_preview.py`
- `tests/test_skills_cli_rows.py`
- `tests/test_tasks_cli_preview.py`
- `tests/test_theme_cli_store.py`
- `tests/test_webhook_cli_mask.py`
## Files intentionally excluded
- `tests/test_backup_cli_security.py` - classifies as `area_security`
(security outranks cli in the taxonomy); moving it into `tests/cli/` would
make the directory disagree with its marker. It belongs with the security
group in a later phase.
- `tests/test_run_focus.py`, `tests/test_taxonomy.py` - taxonomy/runner
infrastructure tests, recently changed (#3556, #3659); they also pin
flat-layout paths (e.g. `tests/test_auth_config_lock_concurrency.py` in
`test_run_focus.py`), so they stay put.
- Script-like but `uncategorized` files - `test_pr_blocker_audit.py`,
`test_update_database_script.py`, `test_windows_update_script.py`,
`test_setup_admin_user.py`, `test_amd_gpu_check_args.py`, `test_hwfit_*.py`.
They exercise `scripts/` too, but moving them would make `tests/cli/`
diverge from the `area_cli` marker set. Reclassify or move them in a later,
separate slice.
- Everything else (security, routes, services, unit, js, helpers) - out of
scope for the first move by design.
## How this was verified
Read-only checks, run from the repo root on this branch. Note the real API is
`classify_test_path` (there is no `classify_test_file`).
```bash
# Compute the area_cli set and confirm test_backup_cli_security.py is
# area_security. Expected: 28 files, then "security".
.venv/bin/python - <<'PY'
from pathlib import Path
from tests._taxonomy import classify_test_path
cli = [p for p in sorted(Path("tests").glob("test_*.py"))
if classify_test_path(p).area == "cli"]
print(len(cli))
for p in cli:
print(p)
print(classify_test_path("tests/test_backup_cli_security.py").area)
PY
# Coupling check across the CLI files. Expected: the only hits are
# "SessionLocal" as stub attribute names passed to tests.helpers.db_stubs;
# no TestClient, FastAPI, create_app, sqlite, or dependency_overrides.
rg -n "TestClient|FastAPI|create_app|SessionLocal|sqlite|dependency_overrides" \
tests/test_*cli*.py tests/test_sessions_cli.py
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
from pathlib import Path
from tests._taxonomy import classify_test_path
for path in sorted(Path("tests").glob("test_*.py")):
if classify_test_path(path).area == "cli":
print(path)
PY2
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
```
Also checked by reading the code: `tests/conftest.py` registers sub-markers
from a recursive `rglob` scan, and `tests/_taxonomy.py` classifies by filename
tokens only (plus the `tests/helpers/` directory rule), so the markers of the
28 files do not change when they move into `tests/cli/`.
## Validation for the future move PR
Run with the project venv (`.venv/bin/python`); system `python3` may miss
pinned deps. Before the move, record the baseline; after, compare:
```bash
# Selection must match the 28 files before and after the move.
.venv/bin/python tests/run_focus.py --dry-run --area cli
.venv/bin/python -m pytest -m area_cli -q
# Moved files pass when targeted directly.
.venv/bin/python -m pytest tests/cli/ -q
# Whole-suite collection still succeeds (catches import/path breakage).
.venv/bin/python -m pytest --collect-only -q
# Taxonomy/runner infrastructure is unaffected.
.venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
# No stale flat-path references to the moved files. Expected: no matches
# outside tests/cli/ itself.
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
from pathlib import Path
from tests._taxonomy import classify_test_path
for path in sorted(Path("tests").glob("test_*.py")):
if classify_test_path(path).area == "cli":
print(path)
PY2
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
```
Pass criteria: identical test counts for `-m area_cli` before/after, zero
collection errors, and no changes outside the moved files.
## Non-goals
- No file moves, renames, or deletions in this PR.
- No changes to `conftest.py`, `_taxonomy.py`, `run_focus.py`, helpers,
markers, CI workflows, or production code.
- No recommendation to split the whole suite at once; later groups get their
own inventory-then-move slices.
+5 -4
View File
@@ -51,10 +51,11 @@ Every new or refactored test should be:
## Test taxonomy
Tests are classified by the categories below. Today the suite is flat under
`tests/`; the **Target dir** column is the phased layout from #2523 that we move
toward *after* helpers and determinism are stable. Until a category is moved,
new tests in that category stay in flat `tests/` but should still follow this
Tests are classified by the categories below. Today the suite is mostly flat
under `tests/` (the current `area_cli` set has moved to `tests/cli/`); the
**Target dir** column is the phased layout from #2523 that we move toward
*after* helpers and determinism are stable. Until a category is moved, new
tests in that category stay in flat `tests/` but should still follow this
standard.
| Category | What it covers | Examples today | Target dir |
+57
View File
@@ -0,0 +1,57 @@
"""`odysseus-research list --status complete` must match completed runs.
Completed research runs are persisted with status "done" (research_handler),
but the user-facing CLI value is the friendlier "complete". The CLI offered
"complete" yet filtered `status != args.status`, so `--status complete` never
matched any record. The fix keeps "complete" as the CLI value and maps it to
the stored "done" at filter time, so the on-disk corpus stays the source of
truth and the documented CLI surface keeps working.
"""
import importlib.machinery
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[2]
def _load_cli():
path = ROOT / "scripts" / "odysseus-research"
loader = importlib.machinery.SourceFileLoader("odysseus_research_cli_status", 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_complete_is_a_valid_status_choice():
cli = _load_cli()
parser = cli._build_parser()
ns = parser.parse_args(["list", "--status", "complete"])
assert ns.status == "complete"
def test_filter_returns_completed_runs(tmp_path, monkeypatch):
cli = _load_cli(); cli._DATA_DIR = tmp_path
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
# CLI "complete" must map to the stored "done" and match r1.
cli.cmd_list(SimpleNamespace(status="complete", limit=50))
ids = [r["id"] for r in emitted[0]]
assert ids == ["r1"] # only the completed run
def test_verbatim_status_still_filters(tmp_path, monkeypatch):
cli = _load_cli(); cli._DATA_DIR = tmp_path
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
emitted = []
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
cli.cmd_list(SimpleNamespace(status="running", limit=50))
ids = [r["id"] for r in emitted[0]]
assert ids == ["r2"] # verbatim choices pass through unchanged
@@ -21,7 +21,7 @@ import json
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
ROOT = Path(__file__).resolve().parents[2]
def _load_cli():
@@ -0,0 +1,43 @@
"""Tool-output display truncation uses _truncate with an indicator.
Previously agent_loop sliced tool output to a hard character limit ([:2000]
or [:4000]) with no signal to the UI that data was lost. Now it delegates to
tool_utils._truncate which caps at MAX_OUTPUT_CHARS (10 000) and appends
a ``... (truncated, N chars total)`` suffix so the frontend can show a
truncation indicator in the tool bubble.
"""
from src.tool_utils import _truncate, MAX_OUTPUT_CHARS
def test_short_output_unchanged():
"""Outputs within the limit pass through verbatim."""
text = "hello world"
assert _truncate(text) == text
def test_long_output_truncated_with_indicator():
"""Outputs exceeding MAX_OUTPUT_CHARS are truncated with a suffix."""
text = "x" * (MAX_OUTPUT_CHARS + 500)
result = _truncate(text)
assert len(result) > MAX_OUTPUT_CHARS # includes suffix
assert result.startswith("x" * MAX_OUTPUT_CHARS)
assert "truncated" in result
assert str(len(text)) in result # original length reported
def test_exact_limit_unchanged():
"""An output exactly at the limit is not truncated."""
text = "a" * MAX_OUTPUT_CHARS
assert _truncate(text) == text
def test_default_limit_matches_constant():
"""_truncate default limit equals MAX_OUTPUT_CHARS (10 000)."""
assert MAX_OUTPUT_CHARS == 10_000
text = "y" * 10_001
result = _truncate(text)
assert "truncated" in result
def test_empty_string():
assert _truncate("") == ""
+16
View File
@@ -33,3 +33,19 @@ def test_api_key_manager_load_resilience(tmp_path):
assert loaded["good_provider"] == "good_value"
assert "bad_provider" not in loaded
assert "garbage_provider" not in loaded
def test_load_ignores_non_string_raw_values(tmp_path):
mgr = APIKeyManager(str(tmp_path))
mgr.save("openai", "sk-openai")
with open(mgr.api_keys_file, "r", encoding="utf-8") as f:
keys = json.load(f)
keys["missing_provider"] = None
keys["numeric_provider"] = 42
keys["object_provider"] = {"encrypted": keys["openai"]}
with open(mgr.api_keys_file, "w", encoding="utf-8") as f:
json.dump(keys, f)
assert mgr.load() == {"openai": "sk-openai"}
+100 -2
View File
@@ -287,8 +287,9 @@ def test_delete_token_deletes_and_invalidates_cache(monkeypatch, token_routes_mo
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
monkeypatch.setattr(mod, "ApiToken", MagicMock())
fake_token = SimpleNamespace(id="abcd1234", owner="alice", name="test")
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.delete.return_value = 1
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
@@ -297,6 +298,7 @@ def test_delete_token_deletes_and_invalidates_cache(monkeypatch, token_routes_mo
resp = delete_token(request=req, token_id="abcd1234")
assert resp == {"status": "deleted"}
fake_session.delete.assert_called_once_with(fake_token)
invalidator.assert_called_once()
@@ -312,7 +314,7 @@ def test_delete_missing_token_returns_404_without_invalidating_cache(monkeypatch
monkeypatch.setattr(mod, "ApiToken", MagicMock())
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.delete.return_value = 0
fake_session.query.return_value.filter.return_value.first.return_value = None
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
@@ -404,3 +406,99 @@ def test_update_missing_token_returns_404(monkeypatch, token_routes_mod):
with pytest.raises(HTTPException) as exc:
asyncio.run(update_token(request=req, token_id="missing99"))
assert exc.value.status_code == 404
# ---------------------------------------------------------------------------
# 7. Owner check — update/delete reject a different admin's token with 403
# ---------------------------------------------------------------------------
def _bob_patch_request(invalidator, body):
"""An admin request from bob whose async .json() yields `body`."""
req = _req("bob", is_admin=True, invalidator=invalidator)
async def _json():
return body
req.json = _json
return req
def test_update_token_rejects_non_owner(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
token = SimpleNamespace(
id="tok123", name="alice-token", owner="alice",
token_prefix="ody_alic", scopes="chat", is_active=True,
)
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _bob_patch_request(MagicMock(), {"name": "hijacked"})
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(update_token(request=req, token_id="tok123"))
assert exc.value.status_code == 403
assert token.name == "alice-token"
def test_delete_token_rejects_non_owner(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
monkeypatch.setattr(mod, "ApiToken", MagicMock())
fake_token = SimpleNamespace(id="tok123", owner="alice", name="alice-token")
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
req = _req("bob", is_admin=True, invalidator=invalidator)
delete_token = _get_handler(mod, "DELETE", "/tokens/{token_id}")
with pytest.raises(HTTPException) as exc:
delete_token(request=req, token_id="tok123")
assert exc.value.status_code == 403
fake_session.delete.assert_not_called()
invalidator.assert_not_called()
def test_update_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "false")
mod = token_routes_mod
monkeypatch.setattr(mod, "get_current_user", lambda req: None)
token = SimpleNamespace(
id="tok123", name="original", owner="alice",
token_prefix="ody_alic", scopes="chat", is_active=True,
)
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _bob_patch_request(MagicMock(), {"name": "renamed-in-single-user"})
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
resp = asyncio.run(update_token(request=req, token_id="tok123"))
assert resp["name"] == "renamed-in-single-user"
def test_delete_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "false")
mod = token_routes_mod
monkeypatch.setattr(mod, "get_current_user", lambda req: None)
monkeypatch.setattr(mod, "ApiToken", MagicMock())
fake_token = SimpleNamespace(id="tok123", owner="alice", name="alice-token")
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
req = _req("", is_admin=True, invalidator=invalidator)
delete_token = _get_handler(mod, "DELETE", "/tokens/{token_id}")
resp = delete_token(request=req, token_id="tok123")
assert resp == {"status": "deleted"}
fake_session.delete.assert_called_once_with(fake_token)
+11 -1
View File
@@ -106,6 +106,9 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
from src.builtin_actions import action_learn_sender_signatures
class FakeImap:
def __init__(self, owner=""):
self.owner = owner
def select(self, *_args, **_kwargs):
return "OK", []
@@ -119,13 +122,20 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
return None
calls, _fallback_calls = _resolver_spy(monkeypatch, utility_result=("", "", {}), default_result=("", "", {}))
monkeypatch.setattr(email_helpers, "_imap_connect", lambda _account_id=None: FakeImap())
imap_owners = []
def fake_imap_connect(_account_id=None, owner=""):
imap_owners.append(owner)
return FakeImap(owner)
monkeypatch.setattr(email_helpers, "_imap_connect", fake_imap_connect)
message, ok = await action_learn_sender_signatures("alice")
assert ok is False
assert message == "No LLM endpoint available"
assert calls == [("utility", "alice"), ("default", "alice")]
assert imap_owners == ["alice"]
@pytest.mark.asyncio
+94
View File
@@ -0,0 +1,94 @@
"""llama.cpp slot-affinity fields must never reach cloud providers (#3793).
_apply_local_cache_affinity adds session_id + cache_prompt to outgoing
payloads for KV-cache slot affinity (#2927). The old gate treated any unknown
OpenAI-compatible host as self-hosted, so strict cloud APIs added as custom
endpoints (Mistral at api.mistral.ai) received the extra fields and rejected
every request with 422 extra_forbidden. Self-hosted now also requires the
endpoint to resolve as local: loopback/private/tailscale host, or endpoint
kind explicitly configured as "local".
"""
import pytest
import src.llm_core as llm_core
import src.model_context as model_context
def _affinity_fields(url, monkeypatch, kind=None):
monkeypatch.setattr(model_context, "_configured_endpoint_kind", lambda _u: kind)
payload = {}
llm_core._apply_local_cache_affinity(payload, url, "sess-123")
return payload
def test_mistral_cloud_api_gets_no_affinity_fields(monkeypatch):
# The #3793 repro: Mistral rejects unknown body fields with 422.
payload = _affinity_fields("https://api.mistral.ai/v1", monkeypatch)
assert payload == {}
def test_openai_api_gets_no_affinity_fields(monkeypatch):
payload = _affinity_fields("https://api.openai.com/v1", monkeypatch)
assert payload == {}
def test_unknown_public_host_gets_no_affinity_fields(monkeypatch):
# Any strict cloud provider added as a custom endpoint, not just Mistral.
payload = _affinity_fields("https://llm.example-cloud.com/v1", monkeypatch)
assert payload == {}
def test_localhost_server_gets_affinity_fields(monkeypatch):
payload = _affinity_fields("http://localhost:8080/v1", monkeypatch)
assert payload == {"session_id": "sess-123", "cache_prompt": True}
def test_private_lan_server_gets_affinity_fields(monkeypatch):
payload = _affinity_fields("http://192.168.1.50:8000/v1", monkeypatch)
assert payload == {"session_id": "sess-123", "cache_prompt": True}
def test_public_host_with_local_kind_override_gets_affinity_fields(monkeypatch):
# Escape hatch: a self-hosted llama.cpp exposed via a tunnel keeps the
# slot-affinity hint when its endpoint kind is configured as "local".
payload = _affinity_fields("https://my-llama.example.com/v1", monkeypatch, kind="local")
assert payload == {"session_id": "sess-123", "cache_prompt": True}
def test_no_session_id_is_a_noop(monkeypatch):
monkeypatch.setattr(model_context, "_configured_endpoint_kind", lambda _u: None)
payload = {}
llm_core._apply_local_cache_affinity(payload, "http://localhost:8080/v1", None)
assert payload == {}
# Cloud-host sweep absorbed from #3839 (credit: Shabablinchikow) - every cloud
# API that falls through provider detection to the OpenAI-compatible default
# must stay clean, not just the Mistral host from the original report.
@pytest.mark.parametrize("url", [
"https://api.mistral.ai/v1/chat/completions",
"https://api.deepseek.com/v1/chat/completions",
"https://api.x.ai/v1/chat/completions",
"https://api.together.xyz/v1/chat/completions",
"https://api.fireworks.ai/inference/v1/chat/completions",
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
])
def test_cloud_openai_compatible_hosts_get_no_affinity_fields(monkeypatch, url):
assert _affinity_fields(url, monkeypatch) == {}
# Tailscale CGNAT boundaries (review finding on #3945): only 100.64.0.0/10 is
# Tailscale; the rest of 100.0.0.0/8 contains public ranges, and a strict
# provider addressed by one must not receive the llama.cpp extras.
def test_host_just_below_cgnat_gets_no_affinity_fields(monkeypatch):
assert _affinity_fields("http://100.63.255.255/v1", monkeypatch) == {}
def test_host_just_above_cgnat_gets_no_affinity_fields(monkeypatch):
assert _affinity_fields("http://100.128.0.1/v1", monkeypatch) == {}
@pytest.mark.parametrize("host", ["100.64.0.1", "100.100.50.2", "100.127.255.254"])
def test_hosts_inside_cgnat_get_affinity_fields(monkeypatch, host):
payload = _affinity_fields(f"http://{host}:8080/v1", monkeypatch)
assert payload == {"session_id": "sess-123", "cache_prompt": True}
+1 -1
View File
@@ -11,7 +11,7 @@ import src.model_context as mc
def _setup(monkeypatch, windows):
"""windows: {endpoint_url: context_length}. Force the remote path."""
monkeypatch.setattr(mc, "_is_local_endpoint", lambda url: False)
monkeypatch.setattr(mc, "is_local_endpoint", lambda url: False)
monkeypatch.setattr(mc, "_configured_endpoint_kind", lambda url: "api")
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: windows[url])
mc._context_cache.clear()
+12
View File
@@ -0,0 +1,12 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DIAGNOSIS_JS = ROOT / "static" / "js" / "cookbook-diagnosis.js"
def test_repair_kernels_pip_spec_is_shell_quoted():
source = DIAGNOSIS_JS.read_text(encoding="utf-8")
assert '"kernels<0.15"' in source
assert " --break-system-packages kernels<0.15" not in source
+56
View File
@@ -0,0 +1,56 @@
"""Behavioral guard for the cookbook error output-tail expansion.
When a task reaches status "error" the status endpoint previously returned
only the last 12 lines of the subprocess log. The "Copy last 50 lines"
context-menu action was therefore copying the same 12 lines — useless for
diagnosing failures that emit long stack traces or build output.
`error_aware_output_tail` now returns the last 50 lines on error and keeps
the cheaper 12-line tail for running/other tasks.
"""
from routes.cookbook_output import error_aware_output_tail
def _snapshot(n):
return "\n".join(f"line {i}" for i in range(n))
def test_error_status_returns_last_50_lines():
snap = _snapshot(200)
tail = error_aware_output_tail(snap, "error")
lines = tail.splitlines()
assert len(lines) == 50, f"error tail should be 50 lines, got {len(lines)}"
assert lines[0] == "line 150"
assert lines[-1] == "line 199"
def test_non_error_status_returns_last_12_lines():
snap = _snapshot(200)
for status in ("running", "ready", "completed", "stopped", "unknown"):
tail = error_aware_output_tail(snap, status)
lines = tail.splitlines()
assert len(lines) == 12, f"{status} tail should be 12 lines, got {len(lines)}"
assert lines[-1] == "line 199"
def test_short_snapshot_returns_all_lines():
# Fewer lines than the cap — return everything, no padding.
snap = _snapshot(5)
assert error_aware_output_tail(snap, "error").splitlines() == [
"line 0", "line 1", "line 2", "line 3", "line 4",
]
assert len(error_aware_output_tail(snap, "running").splitlines()) == 5
def test_empty_snapshot_returns_empty_string():
assert error_aware_output_tail("", "error") == ""
assert error_aware_output_tail("", "running") == ""
def test_error_tail_is_wider_than_non_error():
snap = _snapshot(100)
err = error_aware_output_tail(snap, "error").splitlines()
run = error_aware_output_tail(snap, "running").splitlines()
assert len(err) > len(run)
# The non-error tail is a strict suffix of the error tail.
assert err[-len(run):] == run
+71
View File
@@ -0,0 +1,71 @@
"""Regression tests for _group_uid_fetch_records (Gmail FLAGS placement).
imaplib hands back UID FETCH responses as an interleaved list of
``(meta, literal)`` tuples and bare ``bytes`` elements. Dovecot sends FLAGS
before the RFC822.HEADER literal, so they sit inside the tuple meta; Gmail
sends FLAGS *after* the literal, as a bare ``b' FLAGS (\\Seen))'`` element.
The old grouping loop only looked at tuples, so on Gmail every message lost
its FLAGS and rendered as unread/unflagged in the email library.
"""
import re
from routes.email_routes import _group_uid_fetch_records, _uid_from_fetch_meta
def _flags(meta_b: bytes) -> str:
m = re.search(rb"FLAGS \(([^)]*)\)", meta_b)
return m.group(1).decode() if m else ""
# Captured shape of a real Gmail response to
# UID FETCH a,b (UID FLAGS RFC822.HEADER RFC822.SIZE):
GMAIL_RESPONSE = [
(b"10779 (UID 18723 RFC822.SIZE 54308 RFC822.HEADER {24}", b"Subject: read one\r\n\r\n"),
rb" FLAGS (\Seen))",
(b"10780 (UID 18724 RFC822.SIZE 124310 RFC822.HEADER {26}", b"Subject: unread one\r\n\r\n"),
rb" FLAGS ())",
]
# Dovecot puts FLAGS before the literal and terminates with a bare b')'.
DOVECOT_RESPONSE = [
(rb"1 (UID 5 FLAGS (\Seen) RFC822.SIZE 100 RFC822.HEADER {18}", b"Subject: hi\r\n\r\n"),
b")",
(b"2 (UID 6 FLAGS () RFC822.SIZE 90 RFC822.HEADER {19}", b"Subject: new\r\n\r\n"),
b")",
]
def test_gmail_post_literal_flags_attach_to_their_own_message():
grouped = _group_uid_fetch_records(GMAIL_RESPONSE)
assert len(grouped) == 2
assert _uid_from_fetch_meta(grouped[0][0]) == "18723"
assert _flags(grouped[0][0]) == r"\Seen"
assert grouped[0][1] == b"Subject: read one\r\n\r\n"
assert _uid_from_fetch_meta(grouped[1][0]) == "18724"
assert _flags(grouped[1][0]) == ""
assert grouped[1][1] == b"Subject: unread one\r\n\r\n"
def test_dovecot_pre_literal_flags_unchanged():
grouped = _group_uid_fetch_records(DOVECOT_RESPONSE)
assert len(grouped) == 2
assert _flags(grouped[0][0]) == r"\Seen"
assert _flags(grouped[1][0]) == ""
assert grouped[1][1] == b"Subject: new\r\n\r\n"
def test_size_and_uid_survive_grouping():
grouped = _group_uid_fetch_records(GMAIL_RESPONSE)
sizes = [re.search(rb"RFC822\.SIZE (\d+)", m).group(1) for m, _ in grouped]
assert sizes == [b"54308", b"124310"]
def test_empty_and_none_inputs():
assert _group_uid_fetch_records(None) == []
assert _group_uid_fetch_records([]) == []
# A stray bare element before any tuple opens no record and must not crash.
assert _group_uid_fetch_records([rb" FLAGS (\Seen))"]) == []
+197
View File
@@ -1,5 +1,7 @@
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
@@ -117,6 +119,71 @@ def test_email_ai_cache_tables_are_owner_scoped_and_migrate_legacy_rows(tmp_path
conn.close()
def test_sender_signature_cache_is_owner_scoped_and_migrates_legacy_rows(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE sender_signatures (
from_address TEXT PRIMARY KEY,
signature_text TEXT,
sample_count INTEGER,
last_built_at TEXT NOT NULL,
model_used TEXT,
source TEXT
)
"""
)
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, signature_text, sample_count, last_built_at, model_used, source)
VALUES ('writer@example.com', 'legacy sig', 3, '2026-01-01', 'm', 'llm')
"""
)
conn.commit()
conn.close()
email_helpers._init_scheduled_db()
conn = sqlite3.connect(db_path)
try:
info = conn.execute("PRAGMA table_info(sender_signatures)").fetchall()
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
assert pk_cols == ["from_address", "owner"]
assert conn.execute(
"SELECT owner, signature_text FROM sender_signatures WHERE from_address=?",
("writer@example.com",),
).fetchone() == ("", "legacy sig")
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "alice", "alice sig", 3, "2026-01-02", "m", "llm"),
)
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "bob", "bob sig", 3, "2026-01-03", "m", "llm"),
)
rows = conn.execute(
"SELECT owner, signature_text FROM sender_signatures WHERE from_address=? ORDER BY owner",
("writer@example.com",),
).fetchall()
assert rows == [("", "legacy sig"), ("alice", "alice sig"), ("bob", "bob sig")]
finally:
conn.close()
@pytest.mark.asyncio
async def test_ai_reply_cache_lookup_is_owner_scoped(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
@@ -166,6 +233,136 @@ async def test_ai_reply_cache_lookup_is_owner_scoped(tmp_path, monkeypatch):
assert result["model_used"] == "m-b"
@pytest.mark.asyncio
async def test_sender_signature_read_lookup_is_owner_scoped(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.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "alice", "alice private sig", 3, "2026-01-01", "m-a", "llm"),
)
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "bob", "bob private sig", 3, "2026-01-02", "m-b", "llm"),
)
conn.commit()
conn.close()
raw = (
b"From: Writer <writer@example.com>\r\n"
b"To: Bob <bob@example.com>\r\n"
b"Subject: Hello\r\n"
b"Message-ID: <shared@example.com>\r\n"
b"Date: Tue, 01 Jan 2026 12:00:00 +0000\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"Body"
)
class FakeImap:
def select(self, *_args, **_kwargs):
return "OK", []
def uid(self, command, _uid, query):
assert command == "FETCH"
assert query == "(BODY.PEEK[])"
return "OK", [(b"1 (UID 1 BODY[])", raw)]
@contextmanager
def fake_imap(_account_id=None, owner=""):
assert owner == "bob"
yield FakeImap()
monkeypatch.setattr(email_routes, "_imap", fake_imap)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
result = await read_email("1", folder="INBOX", account_id=None, owner="bob", mark_seen=False)
assert result["sender_signature"] == "bob private sig"
@pytest.mark.asyncio
async def test_sender_signature_clear_cache_keeps_other_owner_rows(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.task_routes as task_routes
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
conn = sqlite3.connect(db_path)
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "alice", "alice private sig", 3, "2026-01-01", "m-a", "llm"),
)
conn.execute(
"""
INSERT INTO sender_signatures
(from_address, owner, signature_text, sample_count, last_built_at, model_used, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("writer@example.com", "bob", "bob private sig", 3, "2026-01-02", "m-b", "llm"),
)
conn.commit()
conn.close()
class FakeQuery:
def filter(self, *_args):
return self
def first(self):
return SimpleNamespace(
id="task-1",
owner="alice",
action="learn_sender_signatures",
)
class FakeDb:
def query(self, _model):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(task_routes, "SessionLocal", lambda: FakeDb())
monkeypatch.setattr(task_routes, "get_current_user", lambda _request: "alice")
router = task_routes.setup_task_routes(task_scheduler=SimpleNamespace(pop_notifications=lambda owner: []))
clear_cache = _route_endpoint(router, "/api/tasks/{task_id}/clear-cache", "POST")
result = await clear_cache(SimpleNamespace(), "task-1")
assert result["cleared"]["sender_signatures"] == 1
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT owner, signature_text FROM sender_signatures ORDER BY owner",
).fetchall()
finally:
conn.close()
assert rows == [("bob", "bob private sig")]
@pytest.mark.asyncio
async def test_scheduled_email_routes_are_owner_scoped(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
@@ -0,0 +1,94 @@
"""Regression guard: Opus 4.7+ rejects the temperature field entirely.
Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
with Claude Opus 4.7 — sending `temperature` at all, even 0.0, returns HTTP 400.
This broke every native-Anthropic call to Opus 4.7/4.8, including the research
endpoint probe (temperature=0) and all DeepResearcher LLM calls, because
_build_anthropic_payload sent `temperature` unconditionally.
Earlier Claude models (Opus 4.6 and below, every Sonnet/Haiku) still accept
temperature in [0.0, 1.0], so the omission is version-gated — the clamp-to-[0,1]
behavior for those models (test_llm_core_anthropic_temp_clamp.py) is unchanged.
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest
from src.llm_core import _anthropic_rejects_temperature, _build_anthropic_payload
@pytest.mark.parametrize(
"model",
[
"claude-opus-4-7",
"claude-opus-4-8",
"claude-opus-4-8-20260101", # tolerate a dated snapshot suffix
"claude-opus-4-7-20260201", # dated 4.7 snapshot — explicit minor, still >= 4.7
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
"claude-opus-4-10", # future minor still >= 4.7
"claude-opus-5-0", # future major
],
)
def test_opus_47_plus_rejects_temperature(model):
assert _anthropic_rejects_temperature(model) is True
@pytest.mark.parametrize(
"model",
[
"claude-opus-4-6",
"claude-opus-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4", # bare major (no minor) — kept
"claude-opus-4-20250514", # Opus 4.0 dated id — the date must NOT read as a 4.7+ minor
"claude-opus-4-1-20250805", # Opus 4.1 dated id — explicit minor before the date
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
"claude-sonnet-4-6",
"claude-3-5-sonnet",
"claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
"claude-haiku-4-5",
"claude-x",
"octopus-4-8", # "opus" only as a substring of another word — must not match
"myproxy/octopus-4-8", # same, behind a provider prefix
"",
None,
],
)
def test_older_claude_models_keep_temperature(model):
assert _anthropic_rejects_temperature(model) is False
@pytest.mark.parametrize("model", [123, 1.5, ["claude-opus-4-8"], {"a": 1}, object()])
def test_non_string_model_is_handled_without_crashing(model):
# Defensive: the gate must not raise on a non-string model (the old builder
# never called .lower() on it). Truthy non-strings should classify as False.
assert _anthropic_rejects_temperature(model) is False
def _payload(model, temperature=0.0):
return _build_anthropic_payload(
model, [{"role": "user", "content": "hi"}], temperature, 100
)
def test_payload_omits_temperature_for_opus_47_plus():
# The endpoint probe sends temperature=0; on Opus 4.7+ that field must be gone.
payload = _payload("claude-opus-4-8", 0.0)
assert "temperature" not in payload
def test_payload_keeps_temperature_for_older_models():
payload = _payload("claude-opus-4-6", 0.3)
assert payload["temperature"] == 0.3
# Older models retain the [0,1] clamp (Nietzsche preset at 1.2 -> 1.0).
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
def test_payload_keeps_temperature_for_dated_opus_4_0():
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
# user's temperature would be silently dropped on a model that accepts it.
assert _payload("claude-opus-4-20250514", 0.5)["temperature"] == 0.5
+66
View File
@@ -14,6 +14,7 @@ import pytest
from fastapi import HTTPException
import routes.memory_routes as mr
from src.request_models import MemoryAddRequest
def _route(router, path, method):
@@ -38,6 +39,13 @@ def _router(monkeypatch, caller):
return mr.setup_memory_routes(mem, sm)
def _request(user):
return SimpleNamespace(
state=SimpleNamespace(current_user=user),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
)
def test_extract_rejects_other_users_session(monkeypatch):
router = _router(monkeypatch, caller="bob")
extract = _route(router, "/api/memory/extract", "POST")
@@ -59,3 +67,61 @@ def test_owner_can_access_own_session(monkeypatch):
gbs = _route(router, "/api/memory/by-session/{session_id}", "GET")
out = gbs(request=None, session_id="alice-sess")
assert out["session_name"] == "Secret project"
def test_add_memory_rejects_other_users_session(monkeypatch):
memory_manager = MagicMock()
session_manager = MagicMock()
memory_vector = MagicMock(healthy=True)
router = mr.setup_memory_routes(
memory_manager=memory_manager,
session_manager=session_manager,
memory_vector=memory_vector,
)
add_memory = _route(router, "/api/memory/add", "POST")
memory_manager.load.return_value = []
memory_manager.find_duplicates.return_value = False
session_manager.get_session.return_value = SimpleNamespace(owner="bob", name="Bob session")
with pytest.raises(HTTPException) as exc:
asyncio.run(
add_memory(
request=_request("alice"),
memory_data=MemoryAddRequest(
text="Alice note",
category="fact",
source="user",
session_id="bob-session",
),
)
)
assert exc.value.status_code == 404
assert exc.value.detail == "Session not found"
session_manager.get_session.assert_called_once_with("bob-session")
memory_manager.add_entry.assert_not_called()
memory_manager.save.assert_not_called()
memory_vector.add.assert_not_called()
def test_timeline_does_not_expose_other_users_session_name():
memory_manager = MagicMock()
session_manager = MagicMock()
session_manager.sessions = {"bob-session": object()}
session_manager.get_session.return_value = SimpleNamespace(owner="bob", name="Bob roadmap")
memory_manager.load.return_value = [
{
"id": "m1",
"text": "Alice note",
"owner": "alice",
"session_id": "bob-session",
"timestamp": 1,
}
]
router = mr.setup_memory_routes(memory_manager, session_manager)
timeline = _route(router, "/api/memory/timeline", "GET")
out = timeline(request=_request("alice"))
assert out["timeline"][0]["session_name"] == "Unknown"
+11 -11
View File
@@ -6,7 +6,7 @@ import types
import pytest
import src.model_context as model_context
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
from src.model_context import is_local_endpoint, estimate_tokens, _lookup_known
class _Column:
@@ -56,20 +56,20 @@ def _install_endpoint_db(monkeypatch, rows):
class TestIsLocalEndpoint:
def test_localhost(self):
assert _is_local_endpoint("http://localhost:5000/v1/chat/completions") is True
assert is_local_endpoint("http://localhost:5000/v1/chat/completions") is True
def test_loopback_ipv4(self):
assert _is_local_endpoint("http://127.0.0.1:8080/v1/chat/completions") is True
assert is_local_endpoint("http://127.0.0.1:8080/v1/chat/completions") is True
def test_private_192_168(self):
assert _is_local_endpoint("http://192.168.1.1:11434/v1/chat/completions") is True
assert is_local_endpoint("http://192.168.1.1:11434/v1/chat/completions") is True
def test_private_10(self):
assert _is_local_endpoint("http://10.0.0.5:8000/v1/chat/completions") is True
assert is_local_endpoint("http://10.0.0.5:8000/v1/chat/completions") is True
def test_tailscale_100(self):
# 100.64.0.0/10 is the CGNAT range Tailscale uses.
assert _is_local_endpoint("http://100.64.0.1:5000/v1/chat/completions") is True
assert is_local_endpoint("http://100.64.0.1:5000/v1/chat/completions") is True
def test_configured_tailscale_proxy_is_remote(self, monkeypatch):
_install_endpoint_db(monkeypatch, [
@@ -81,19 +81,19 @@ class TestIsLocalEndpoint:
)
])
assert _is_local_endpoint("http://100.117.136.97:34521/v1/chat/completions") is False
assert is_local_endpoint("http://100.117.136.97:34521/v1/chat/completions") is False
def test_openai_is_remote(self):
assert _is_local_endpoint("https://api.openai.com/v1/chat/completions") is False
assert is_local_endpoint("https://api.openai.com/v1/chat/completions") is False
def test_anthropic_is_remote(self):
assert _is_local_endpoint("https://api.anthropic.com/v1/messages") is False
assert is_local_endpoint("https://api.anthropic.com/v1/messages") is False
def test_empty_url(self):
assert _is_local_endpoint("") is False
assert is_local_endpoint("") is False
def test_malformed_url(self):
assert _is_local_endpoint("not-a-url") is False
assert is_local_endpoint("not-a-url") is False
class TestEstimateTokens:
+70 -2
View File
@@ -54,6 +54,7 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
_endpoint_settings_using_endpoint,
_clear_endpoint_settings_for_endpoint,
_clear_user_pref_endpoint_refs,
_default_endpoint_needs_assignment,
_PROVIDER_CURATED,
)
from src.llm_core import ANTHROPIC_MODELS
@@ -154,6 +155,26 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
assert legacy["default_model_fallbacks"] == []
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──
def test_default_assignment_when_none_configured():
# Nothing configured yet → first added endpoint should become the default.
assert _default_endpoint_needs_assignment("", {"a", "b"}) is True
def test_default_assignment_when_current_default_disabled():
# #3586: the configured default points at an endpoint that is no longer
# enabled (the user disabled it). Adding a new endpoint must reassign the
# default — otherwise Memory → Tidy keeps failing with "No default model
# configured" even though an enabled endpoint exists.
assert _default_endpoint_needs_assignment("disabled-ep", {"new-ep"}) is True
def test_default_preserved_when_current_default_enabled():
# Normal case: the configured default is still enabled → leave it alone.
assert _default_endpoint_needs_assignment("live-ep", {"live-ep", "new-ep"}) is False
# ── _match_provider_curated ──
class TestMatchProviderCurated:
@@ -966,16 +987,21 @@ def _create_form_kwargs(**overrides):
return kwargs
def _patch_create_deps(monkeypatch, db):
def _patch_create_deps(monkeypatch, db, settings=None):
import src.auth_helpers as auth_helpers
# Shared, in-memory settings so the auto-default write path stays hermetic
# (no real settings.json). Returned so tests can assert what was persisted.
settings = {"default_endpoint_id": "exists"} if settings is None else settings
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RecordingEndpoint)
monkeypatch.setattr(model_routes, "_normalize_base", lambda b: b)
monkeypatch.setattr(model_routes, "_rewrite_loopback_for_docker", lambda b, **k: b)
monkeypatch.setattr(model_routes, "_load_settings", lambda: {"default_endpoint_id": "exists"})
monkeypatch.setattr(model_routes, "_load_settings", lambda: settings)
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
return settings
def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
@@ -1091,6 +1117,48 @@ def test_post_same_base_url_different_api_key_creates_distinct_endpoint(monkeypa
assert db.added[0].api_key == "key-two"
def test_post_reassigns_default_when_current_default_disabled(monkeypatch):
# #3586: the configured default points at a now-disabled endpoint. Adding a
# new endpoint must promote it to the default, otherwise raw-setting readers
# (Memory → Tidy) keep failing with "No default model configured".
disabled = _make_endpoint(id="dead", base_url="http://old-host/v1", is_enabled=False)
db = _PinnedFakeDb([disabled])
settings = _patch_create_deps(
monkeypatch, db, settings={"default_endpoint_id": "dead", "default_model": "stale"}
)
create = _get_route("/api/model-endpoints", "POST")
create(
_PinnedFakeRequest(),
base_url="http://new-host:1234/v1",
**_create_form_kwargs(),
)
new_id = db.added[0].id
assert settings["default_endpoint_id"] == new_id
assert settings["default_endpoint_id"] != "dead"
def test_post_keeps_default_when_current_default_enabled(monkeypatch):
# Counter-case: an enabled default must be left untouched when another
# endpoint is added.
live = _make_endpoint(id="live", base_url="http://live-host/v1", is_enabled=True)
db = _PinnedFakeDb([live])
settings = _patch_create_deps(
monkeypatch, db, settings={"default_endpoint_id": "live", "default_model": "live-model"}
)
create = _get_route("/api/model-endpoints", "POST")
create(
_PinnedFakeRequest(),
base_url="http://another-host:1234/v1",
**_create_form_kwargs(),
)
assert settings["default_endpoint_id"] == "live"
assert settings["default_model"] == "live-model"
def test_post_same_base_url_same_api_key_still_dedupes(monkeypatch):
existing = _make_endpoint(
base_url="https://api.example.test/v1",
+14
View File
@@ -47,6 +47,20 @@ def test_find_bash_checks_local_app_data_git_install(monkeypatch):
assert platform_compat.find_bash() == expected
def test_find_bash_checks_local_app_data_programs_git_install(monkeypatch):
_reset_bash_cache(monkeypatch)
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
monkeypatch.setattr(platform_compat.shutil, "which", lambda _name: None)
for env_name in platform_compat._WINDOWS_BASH_ROOT_ENV_VARS:
monkeypatch.delenv(env_name, raising=False)
monkeypatch.setenv("LocalAppData", r"C:\Users\alice\AppData\Local")
expected = r"C:\Users\alice\AppData\Local\Programs\Git\bin\bash.exe"
monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected)
assert platform_compat.find_bash() == expected
def test_find_bash_skips_windows_wsl_stub(monkeypatch):
_reset_bash_cache(monkeypatch)
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
+58 -5
View File
@@ -1,4 +1,4 @@
"""Renaming a user must update all three owner caches, not just the SQL DB.
"""Renaming a user must update non-SQL owner stores, 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:
@@ -17,6 +17,9 @@ owner column, but three file-backed / in-memory stores are left stale:
4. 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.
5. data/uploads/uploads.json — each upload row carries an `owner` field and
owner-prefixed index key; stale metadata denies renamed users their uploads.
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.
"""
@@ -67,11 +70,12 @@ def rename_endpoint(monkeypatch, tmp_path):
return _route(ar.setup_auth_routes(am), "rename_user"), am, tmp_path
def _request(tmp_path, session_manager=None, token="t", research_handler=None):
def _request(tmp_path, session_manager=None, token="t", research_handler=None, upload_handler=None):
state = SimpleNamespace(
invalidate_token_cache=lambda: None,
session_manager=session_manager,
research_handler=research_handler,
upload_handler=upload_handler,
)
return SimpleNamespace(
cookies={"odysseus_session": token},
@@ -415,7 +419,56 @@ def test_rename_no_memory_json_does_not_crash(rename_endpoint):
# ---------------------------------------------------------------------------
# 4. Skills (SKILL.md frontmatter + _usage.json sidecar)
# 4. uploads.json
# ---------------------------------------------------------------------------
def test_rename_updates_upload_metadata_owner(rename_endpoint):
endpoint, _am, tmp_path = rename_endpoint
from src.upload_handler import UploadHandler
upload_dir = tmp_path / "uploads"
dated = upload_dir / "2026" / "06" / "09"
dated.mkdir(parents=True)
upload_id = "a" * 32 + ".txt"
upload_path = dated / upload_id
upload_path.write_text("alice private upload", encoding="utf-8")
handler = UploadHandler(str(tmp_path), str(upload_dir))
handler._atomic_write_json(
str(upload_dir / "uploads.json"),
{
"alice:hash-alice": {
"id": upload_id,
"path": str(upload_path),
"mime": "text/plain",
"size": upload_path.stat().st_size,
"name": "note.txt",
"hash": "hash-alice",
"original_name": "note.txt",
"uploaded_at": "2026-06-09T10:00:00",
"last_accessed": "2026-06-09T10:00:00",
"client_ip": "127.0.0.1",
"owner": "alice",
},
},
)
asyncio.run(
endpoint(
"alice",
SimpleNamespace(username="alice2"),
_request(tmp_path, upload_handler=handler),
)
)
updated = json.loads((upload_dir / "uploads.json").read_text(encoding="utf-8"))
assert "alice:hash-alice" not in updated
assert updated["alice2:hash-alice"]["owner"] == "alice2"
assert handler.resolve_upload(upload_id, owner="alice2")["path"] == str(upload_path)
assert handler.resolve_upload(upload_id, owner="alice") is None
# ---------------------------------------------------------------------------
# 5. Skills (SKILL.md frontmatter + _usage.json sidecar)
# ---------------------------------------------------------------------------
_SKILL_MD = """\
@@ -522,7 +575,7 @@ def test_rename_usage_keys_case_insensitive(rename_endpoint):
# ---------------------------------------------------------------------------
# 5. Rollback: auth rename must be restored if SQL owner migration fails
# 6. Rollback: auth rename must be restored if SQL owner migration fails
# ---------------------------------------------------------------------------
def test_owner_migration_failure_rolls_back_auth_rename(monkeypatch, tmp_path):
@@ -583,7 +636,7 @@ def test_self_rename_owner_migration_failure_rolls_back_auth_session(monkeypatch
# ---------------------------------------------------------------------------
# 6. P1 regression: rejected auth rename must not mutate file-backed stores
# 7. P1 regression: rejected auth rename must not mutate file-backed stores
# ---------------------------------------------------------------------------
def test_rejected_rename_does_not_mutate_files(monkeypatch, tmp_path):
+55
View File
@@ -0,0 +1,55 @@
"""FTS session search must fetch hit rows in one query, not one per hit.
_search_fts looked up each FTS hit's full row with its own
db.query(...).filter(id == message_id).first(), an N+1 query. The lookup is now
a single batched IN(...) query via _fetch_messages_by_id.
"""
from src.session_search import _fetch_messages_by_id
class _Msg:
def __init__(self, mid):
self.id = mid
class _Query:
def __init__(self, rows, calls):
self._rows = rows
self._calls = calls
def join(self, *a, **k):
return self
def filter(self, *a, **k):
return self
def all(self):
self._calls["all"] += 1
return self._rows
class _DB:
def __init__(self, rows):
self._rows = rows
self.calls = {"query": 0, "all": 0}
def query(self, *a, **k):
self.calls["query"] += 1
return _Query(self._rows, self.calls)
def test_batches_into_single_query():
rows = [(_Msg("m1"), "Session One"), (_Msg("m2"), "Session Two")]
db = _DB(rows)
out = _fetch_messages_by_id(db, ["m1", "m2"])
# One query for all hits, not one per hit.
assert db.calls["query"] == 1
assert db.calls["all"] == 1
assert out["m1"][1] == "Session One"
assert out["m2"][0].id == "m2"
def test_empty_ids_does_no_query():
db = _DB([])
assert _fetch_messages_by_id(db, []) == {}
assert db.calls["query"] == 0
+19 -1
View File
@@ -40,7 +40,8 @@ def test_secret_in_list_of_dicts_blanked():
def test_non_secret_keys_preserved():
s = {"keybinds": {"send": "Enter"}, "theme": "dark", "image_model": "x",
"default_endpoint_id": "ep1", "search_result_count": 5, "tts_enabled": True}
"default_endpoint_id": "ep1", "search_result_count": 5, "tts_enabled": True,
"tokenId": "public-id", "keyId": "public-key-id"}
assert scrub_settings(s) == s # untouched
@@ -71,6 +72,23 @@ def test_exact_name_matches():
assert all(v == "" for v in out.values()), out
def test_camel_case_secret_keys_blanked():
out = scrub_settings({
"apiKey": "api-secret",
"accessToken": "access-secret",
"refreshToken": "refresh-secret",
"clientSecret": "client-secret",
"hfToken": "hf-secret",
"nested": {"privateKey": "private-secret"},
})
assert out["apiKey"] == ""
assert out["accessToken"] == ""
assert out["refreshToken"] == ""
assert out["clientSecret"] == ""
assert out["hfToken"] == ""
assert out["nested"]["privateKey"] == ""
def test_non_object_settings_return_empty_mapping():
assert scrub_settings(["not", "settings"]) == {}
assert scrub_settings("not settings") == {}
+101
View File
@@ -0,0 +1,101 @@
import json
import os
from pathlib import Path
from src.upload_handler import UploadHandler
def _make_handler(tmp_path: Path) -> UploadHandler:
base = tmp_path / "base"
upload = tmp_path / "uploads"
base.mkdir()
upload.mkdir()
return UploadHandler(base_dir=str(base), upload_dir=str(upload))
def _db_path(handler: UploadHandler) -> str:
return os.path.join(handler.upload_dir, "uploads.json")
def _write_upload_file(handler: UploadHandler, file_id: str, content: bytes = b"content") -> str:
upload_day = Path(handler.upload_dir) / "2026" / "06" / "09"
upload_day.mkdir(parents=True, exist_ok=True)
path = upload_day / file_id
path.write_bytes(content)
return str(path)
def _entry(handler: UploadHandler, owner: str, file_hash: str, file_id: str) -> dict:
path = _write_upload_file(handler, file_id, content=f"{owner}:{file_hash}".encode())
return {
"id": file_id,
"path": path,
"mime": "text/plain",
"size": os.path.getsize(path),
"name": f"{file_id}.txt",
"hash": file_hash,
"original_name": f"{file_id}.txt",
"uploaded_at": "2026-06-09T10:00:00",
"last_accessed": "2026-06-09T10:00:00",
"client_ip": "127.0.0.1",
"owner": owner,
}
def test_rename_owner_updates_upload_metadata_key_and_resolver(tmp_path):
handler = _make_handler(tmp_path)
alice_id = "a" * 32 + ".txt"
alice_entry = _entry(handler, "Alice", "hash-alice", alice_id)
bob_entry = _entry(handler, "bob", "hash-bob", "b" * 32 + ".txt")
handler._atomic_write_json(
_db_path(handler),
{
"Alice:hash-alice": alice_entry,
"bob:hash-bob": bob_entry,
},
)
renamed = handler.rename_owner("alice", "alice2")
assert renamed == 1
updated = json.loads(Path(_db_path(handler)).read_text(encoding="utf-8"))
assert "Alice:hash-alice" not in updated
assert "alice2:hash-alice" in updated
assert updated["alice2:hash-alice"]["owner"] == "alice2"
assert updated["alice2:hash-alice"]["path"] == alice_entry["path"]
assert updated["alice2:hash-alice"]["hash"] == alice_entry["hash"]
assert updated["alice2:hash-alice"]["uploaded_at"] == alice_entry["uploaded_at"]
assert updated["alice2:hash-alice"]["last_accessed"] == alice_entry["last_accessed"]
assert updated["bob:hash-bob"]["owner"] == "bob"
assert handler.resolve_upload(alice_id, owner="alice2")["id"] == alice_id
assert handler.resolve_upload(alice_id, owner="alice") is None
def test_rename_owner_preserves_rows_when_target_key_collides(tmp_path):
handler = _make_handler(tmp_path)
migrated_id = "c" * 32 + ".txt"
existing_id = "d" * 32 + ".txt"
migrated = _entry(handler, "alice", "same-hash", migrated_id)
existing = _entry(handler, "alice2", "same-hash", existing_id)
unrelated = _entry(handler, "carol", "other-hash", "e" * 32 + ".txt")
handler._atomic_write_json(
_db_path(handler),
{
"alice:same-hash": migrated,
"alice2:same-hash": existing,
"carol:other-hash": unrelated,
},
)
renamed = handler.rename_owner("alice", "alice2")
assert renamed == 1
updated = json.loads(Path(_db_path(handler)).read_text(encoding="utf-8"))
assert len(updated) == 3
assert updated["alice2:same-hash"]["id"] == existing_id
migrated_key = f"alice2:same-hash:{migrated_id}"
assert updated[migrated_key]["id"] == migrated_id
assert updated[migrated_key]["owner"] == "alice2"
assert updated[migrated_key]["path"] == migrated["path"]
assert updated["carol:other-hash"] == unrelated
+110
View File
@@ -0,0 +1,110 @@
"""fetch_webpage_content must return plain-text and Markdown bodies verbatim.
raw.githubusercontent.com serves Markdown as `text/plain`, and a lot of code
and tool documentation lives in `.md` / `.txt`. Those have no HTML structure,
so the HTML branch extracted nothing and web_fetch reported "no readable text
content". The plain-text branch returns the body as-is. HTML stays on the
parsing path.
"""
import types
import pytest
from services.search import content as content_mod
class _FakeResponse:
def __init__(self, text, content_type, status_code=200):
self.text = text
self.content = text.encode("utf-8")
self.headers = {"Content-Type": content_type}
self.status_code = status_code
def raise_for_status(self):
return None
@pytest.fixture
def no_cache(monkeypatch, tmp_path):
# Force a cache miss and skip disk writes so the test is hermetic.
monkeypatch.setattr(content_mod, "CONTENT_CACHE_DIR", tmp_path)
monkeypatch.setattr(content_mod, "_cache_result", lambda *a, **k: None)
def _patch_fetch(monkeypatch, text, content_type):
monkeypatch.setattr(
content_mod,
"_get_public_url",
lambda url, headers=None, timeout=5: _FakeResponse(text, content_type),
)
MARKDOWN = "# Title\n\nSome **docs** with a [link](https://example.com).\n"
def test_markdown_text_plain_returns_body(monkeypatch, no_cache):
_patch_fetch(monkeypatch, MARKDOWN, "text/plain; charset=utf-8")
r = content_mod.fetch_webpage_content(
"https://raw.githubusercontent.com/o/r/master/Documentation/Patterns.md"
)
assert r["success"] is True
assert r["content"] == MARKDOWN.strip()
assert r["title"] == "patterns.md"
assert r["error"] == ""
def test_text_markdown_content_type_returns_body(monkeypatch, no_cache):
_patch_fetch(monkeypatch, MARKDOWN, "text/markdown")
r = content_mod.fetch_webpage_content("https://example.com/readme")
assert r["success"] is True
assert r["content"] == MARKDOWN.strip()
def test_octet_stream_with_txt_suffix_returns_body(monkeypatch, no_cache):
# Some servers mislabel text files; the URL-suffix fallback still reads it.
_patch_fetch(monkeypatch, "plain notes\nline two\n", "application/octet-stream")
r = content_mod.fetch_webpage_content("https://example.com/notes.txt")
assert r["success"] is True
assert r["content"] == "plain notes\nline two"
def test_application_json_returns_body(monkeypatch, no_cache):
# application/json is not text/*; it must still be returned verbatim
# instead of being fed to the HTML parser (which yields empty content).
body = '{"name": "odysseus", "items": [1, 2, 3]}'
_patch_fetch(monkeypatch, body, "application/json")
r = content_mod.fetch_webpage_content("https://api.example.com/data")
assert r["success"] is True
assert r["content"] == body
def test_ld_json_suffix_content_type_returns_body(monkeypatch, no_cache):
body = '{"@context": "https://schema.org"}'
_patch_fetch(monkeypatch, body, "application/ld+json")
r = content_mod.fetch_webpage_content("https://example.com/meta")
assert r["success"] is True
assert r["content"] == body
def test_json_suffix_with_octet_stream_returns_body(monkeypatch, no_cache):
body = '{"raw": true}'
_patch_fetch(monkeypatch, body, "application/octet-stream")
r = content_mod.fetch_webpage_content("https://example.com/package.json")
assert r["success"] is True
assert r["content"] == body
def test_empty_text_body_is_not_success(monkeypatch, no_cache):
_patch_fetch(monkeypatch, " \n ", "text/plain")
r = content_mod.fetch_webpage_content("https://example.com/blank.txt")
assert r["success"] is False
assert r["content"] == ""
def test_html_still_uses_parser(monkeypatch, no_cache):
# An HTML body must not be short-circuited by the text branch.
html = "<html><head><title>Hi</title></head><body><p>Hello world body text</p></body></html>"
_patch_fetch(monkeypatch, html, "text/html; charset=utf-8")
r = content_mod.fetch_webpage_content("https://example.com/page")
assert r["title"] == "Hi"
assert "Hello world body text" in r["content"]
+55
View File
@@ -0,0 +1,55 @@
"""Fire-and-forget webhook tasks must be referenced until they finish.
asyncio keeps only a weak reference to a bare create_task() result, so a
delivery task could be garbage-collected before it ran and the webhook silently
dropped. WebhookManager now holds a strong reference for the task's lifetime and
releases it on completion.
"""
import asyncio
import sys
# webhook_manager does `from src.database import SessionLocal, Webhook` at import
# time. The shared test harness stubs src.database without Webhook, so ensure the
# attribute exists before importing the manager. These tests never touch the DB
# (the manager is built via __new__), so a placeholder class is sufficient.
_db = sys.modules.get("src.database")
if _db is not None and not hasattr(_db, "Webhook"):
_db.Webhook = type("Webhook", (), {})
from src.webhook_manager import WebhookManager # noqa: E402
def test_spawn_tracked_holds_then_releases_reference():
async def run():
wm = WebhookManager.__new__(WebhookManager)
wm._bg_tasks = set()
gate = asyncio.Event()
async def work():
await gate.wait()
task = wm._spawn_tracked(work())
# Referenced while in flight (this is what stops GC from collecting it).
assert task in wm._bg_tasks
gate.set()
await task
# Reference released once done, so the set does not grow unbounded.
assert task not in wm._bg_tasks
asyncio.run(run())
def test_spawn_tracked_runs_the_coroutine():
async def run():
wm = WebhookManager.__new__(WebhookManager)
wm._bg_tasks = set()
ran = []
async def work():
ran.append(True)
await wm._spawn_tracked(work())
assert ran == [True]
asyncio.run(run())
+328
View File
@@ -0,0 +1,328 @@
"""Workspace confinement.
The agent's per-turn workspace is a single context-local binding set in
execute_tool_block. The shared path resolvers (_resolve_tool_path /
_resolve_search_root) and the subprocess cwd helper (agent_cwd) read it, so
confinement is enforced in ONE place: a tool that uses the shared helpers is
confined automatically and a new tool cannot accidentally bypass it.
Covers: the resolver helper, the central binding (the safety net), end-to-end
confinement of read/write/edit/grep/ls + subprocess cwd via execute_tool_block,
the get_workspace tool, no-leak across calls, and the admin-gated browse route.
"""
import json
import os
import tempfile
from types import SimpleNamespace
import pytest
from src.tool_execution import (
_AGENT_WORKDIR,
_active_workspace,
_resolve_search_root,
_resolve_tool_path,
_resolve_tool_path_in_workspace,
agent_cwd,
execute_tool_block,
get_active_workspace,
)
def _block(tool, content=""):
return SimpleNamespace(tool_type=tool, content=content)
@pytest.fixture
def ws():
d = tempfile.mkdtemp()
with open(os.path.join(d, "a.txt"), "w") as f:
f.write("x")
return d
@pytest.fixture
def admin(monkeypatch):
"""Pass the public-tool gate so file tools dispatch in tests."""
monkeypatch.setattr(
"src.tool_execution.owner_is_admin_or_single_user", lambda owner: True
)
# ── the resolver helper ────────────────────────────────────────────────
def test_resolver_confines(ws):
real = os.path.realpath(os.path.join(ws, "a.txt"))
assert _resolve_tool_path_in_workspace(ws, "a.txt") == real # relative
assert _resolve_tool_path_in_workspace(ws, os.path.join(ws, "a.txt")) == real # abs inside
outside = tempfile.mkdtemp()
with pytest.raises(ValueError): # abs outside
_resolve_tool_path_in_workspace(ws, os.path.join(outside, "x.txt"))
with pytest.raises(ValueError): # parent escape
_resolve_tool_path_in_workspace(ws, os.path.join("..", "..", "escape.txt"))
def test_resolver_blocks_sensitive_inside_workspace(ws):
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
with pytest.raises(ValueError):
_resolve_tool_path_in_workspace(ws, ".ssh/authorized_keys")
# ── the central binding: the safety net ─────────────────────────────────
def test_active_binding_confines_shared_resolvers(ws):
"""ANY tool resolving paths through the shared helpers is confined while the
binding is active, without doing anything workspace-specific itself. This is
what stops a newly added tool from accidentally ignoring the workspace."""
token = _active_workspace.set(ws)
try:
assert get_active_workspace() == ws
assert agent_cwd() == ws
assert _resolve_tool_path("a.txt") == os.path.realpath(os.path.join(ws, "a.txt"))
with pytest.raises(ValueError): # normally-allowed root, now outside ws
_resolve_tool_path("/tmp/whatever.txt")
assert _resolve_search_root("") == os.path.realpath(ws)
finally:
_active_workspace.reset(token)
def test_no_binding_uses_default_roots():
assert get_active_workspace() is None
assert agent_cwd() == _AGENT_WORKDIR
with pytest.raises(ValueError):
_resolve_tool_path("/etc/hosts")
# ── end-to-end via execute_tool_block (sets + resets the binding) ───────
@pytest.mark.asyncio
async def test_read_write_edit_confined_e2e(ws, admin):
_, r = await execute_tool_block(_block("write_file", "note.txt\nhello"), owner="a", workspace=ws)
assert r["exit_code"] == 0 and os.path.isfile(os.path.join(ws, "note.txt"))
_, r = await execute_tool_block(_block("read_file", "note.txt"), owner="a", workspace=ws)
assert r["exit_code"] == 0 and r["output"] == "hello"
with open(os.path.join(ws, "f.txt"), "w") as f:
f.write("foo bar")
_, r = await execute_tool_block(
_block("edit_file", json.dumps({"path": "f.txt", "old_string": "foo", "new_string": "baz"})),
owner="a", workspace=ws,
)
assert r["exit_code"] == 0
with open(os.path.join(ws, "f.txt")) as f:
assert f.read() == "baz bar"
# outside the workspace is rejected, and nothing is created
outside = tempfile.mkdtemp()
of = os.path.join(outside, "secret.txt")
with open(of, "w") as f:
f.write("nope")
_, r = await execute_tool_block(_block("read_file", of), owner="a", workspace=ws)
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
escape = os.path.join(outside, "_esc.txt")
_, r = await execute_tool_block(_block("write_file", f"{escape}\nx"), owner="a", workspace=ws)
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
assert not os.path.exists(escape)
@pytest.mark.asyncio
async def test_grep_and_ls_confined_e2e(ws, admin):
with open(os.path.join(ws, "doc.txt"), "w") as f:
f.write("hello workspace\n")
_, r = await execute_tool_block(_block("grep", json.dumps({"pattern": "hello"})), owner="a", workspace=ws)
assert r["exit_code"] == 0 and "doc.txt" in r["output"]
outside = tempfile.mkdtemp()
_, r = await execute_tool_block(_block("grep", json.dumps({"pattern": "x", "path": outside})), owner="a", workspace=ws)
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
_, r = await execute_tool_block(_block("ls", ""), owner="a", workspace=ws)
assert r["exit_code"] == 0 and "doc.txt" in r["output"]
_, r = await execute_tool_block(_block("ls", outside), owner="a", workspace=ws)
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
@pytest.mark.asyncio
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
"""python tool runs with cwd = workspace (OS-agnostic probe)."""
_, r = await execute_tool_block(_block("python", "import os; print(os.getcwd())"), owner="a", workspace=ws)
assert r["exit_code"] == 0
assert os.path.realpath(r["output"].strip()) == os.path.realpath(ws)
# ── get_workspace tool ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_workspace_tool(ws, admin):
_, r = await execute_tool_block(_block("get_workspace", ""), owner="a", workspace=ws)
assert r["exit_code"] == 0 and r["output"].startswith(ws) and "not sandboxed" in r["output"]
_, r = await execute_tool_block(_block("get_workspace", ""), owner="a") # none active
assert r["exit_code"] == 0 and "No workspace" in r["output"]
# ── no leak across calls ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_binding_does_not_leak(ws, admin):
await execute_tool_block(_block("ls", ""), owner="a", workspace=ws)
assert get_active_workspace() is None
# ── tool selection: an active workspace is the file-work signal ─────────
# A vague ("low-signal") message like "look at the local project" matches no
# domain keywords, so retrieval is normally skipped. When a workspace is set it
# 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):
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)
# Isolate the selection logic from owner gating (tested separately).
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
captured = []
async def _fake_stream(_candidates, messages, **kwargs):
captured.append(kwargs.get("tools"))
yield "data: " + json.dumps({"delta": "ok"}) + "\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-test",
[{"role": "user", "content": "look at the local project"}],
max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace,
)
return [c async for c in gen]
asyncio.run(_run())
schemas = captured[0] or []
return {t["function"]["name"] for t in schemas if isinstance(t, dict) and "function" in t}
def test_low_signal_with_workspace_surfaces_readonly_file_tools(monkeypatch):
names = _sent_tool_names(monkeypatch, workspace="/tmp")
# read-only nav tools surface so the agent can explore
assert "read_file" in names
assert "get_workspace" in names
assert "grep" in names
# write/shell tools do NOT surface on a vague message
assert "write_file" not in names
assert "edit_file" not in names
assert "bash" not in names
assert "python" not 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
# ── browse route is admin-gated ─────────────────────────────────────────
def test_browse_is_admin_gated(monkeypatch):
from fastapi import HTTPException
import routes.workspace_routes as wr
router = wr.setup_workspace_routes()
browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
monkeypatch.setattr(wr, "get_current_user", lambda req: "bob")
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
with pytest.raises(HTTPException) as ei:
browse(request=object(), path="/")
assert ei.value.status_code == 403
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
out = browse(request=object(), path=os.path.expanduser("~"))
assert "dirs" in out and "path" in out
assert all("name" in d and "path" in d for d in out["dirs"])
# ── bind-time vetting of the workspace root ─────────────────────────────
def test_vet_workspace_accepts_normal_dir(ws):
from src.tool_execution import vet_workspace
assert vet_workspace(ws) == os.path.realpath(ws)
def test_vet_workspace_rejects_sensitive_root(tmp_path):
# The resolver deny-lists sensitive paths inside the workspace, but the
# empty-path search root is the workspace itself - a sensitive root must
# be rejected before it is bound or `ls` with no path would list it.
from src.tool_execution import vet_workspace
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir()
assert vet_workspace(str(ssh_dir)) is None
def test_vet_workspace_rejects_nondir_and_empty(ws):
from src.tool_execution import vet_workspace
assert vet_workspace(os.path.join(ws, "a.txt")) is None # file, not dir
assert vet_workspace("/nonexistent/path/xyz") is None
assert vet_workspace("") is None
assert vet_workspace(" ") is None
def test_vet_workspace_rejects_filesystem_root():
# Binding / would make every absolute path "inside" the workspace,
# collapsing confinement into host-wide file access.
from src.tool_execution import vet_workspace
assert vet_workspace("/") is None
def test_browse_marks_root_unselectable_and_vet_endpoint(monkeypatch):
import routes.workspace_routes as wr
router = wr.setup_workspace_routes()
browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
vet = next(r.endpoint for r in router.routes if r.path == "/api/workspace/vet")
monkeypatch.setattr(wr, "get_current_user", lambda req: "admin")
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
out = browse(request=object(), path="/")
assert out["selectable"] is False
out = browse(request=object(), path=os.path.expanduser("~"))
assert out["selectable"] is True
assert vet(request=object(), path="/") == {"ok": False, "path": None}
home = os.path.realpath(os.path.expanduser("~"))
assert vet(request=object(), path="~") == {"ok": True, "path": home}
from fastapi import HTTPException
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
with pytest.raises(HTTPException) as ei:
vet(request=object(), path="/tmp")
assert ei.value.status_code == 403
# ── send-time privilege gate (no path oracle for non-admins) ────────────
def test_request_workspace_gate(ws, monkeypatch):
"""Non-admin chat callers must get a uniform drop with no vetting: the
workspace_rejected signal would otherwise reveal which host paths exist."""
import routes.chat_routes as cr
monkeypatch.setattr(cr, "get_current_user", lambda req: "bob")
vet_calls = []
import src.tool_execution as te
real_vet = te.vet_workspace
monkeypatch.setattr(te, "vet_workspace", lambda p: vet_calls.append(p) or real_vet(p))
import src.tool_security as ts
monkeypatch.setattr(ts, "owner_is_admin_or_single_user", lambda owner: False)
# Valid and invalid paths are indistinguishable for a non-admin: both
# drop silently, and the path never reaches the filesystem.
assert cr._resolve_request_workspace(object(), ws) == ("", "")
assert cr._resolve_request_workspace(object(), "/nonexistent/xyz") == ("", "")
assert vet_calls == []
monkeypatch.setattr(ts, "owner_is_admin_or_single_user", lambda owner: True)
assert cr._resolve_request_workspace(object(), ws) == (os.path.realpath(ws), "")
assert cr._resolve_request_workspace(object(), "/nonexistent/xyz") == ("", "/nonexistent/xyz")