fix: resolve all critical runtime errors and bugs from audit

- Add COMPLETIONS_API_KEY to config.py (env var + auto-generated fallback)
- Fix perplexity auto-search: upstream sends logprobs=true, parse_llama_stream_chunk
  extracts per-token logprobs, all_logprobs populated during streaming
- Fix all /api/models endpoints to target LLAMA_SERVER_BASE (port 8081) not OLLAMA_BASE
- Fix RAG embedding endpoint URL from port 11434 (Ollama) to 8081 (llama-server)
- Correct misleading error messages: 'inference server' not 'Ollama'
- Remove raw_results leak from SSE event stream in /api/search
- Fix weather query extractor: pattern-match instead of unconditional suffix append
- Escape FTS5 operator keywords (AND/OR/NOT/NEAR) in memory search
- Move auth.py BODY_LIMIT_DEFAULT_BYTES imports to module level
- Change RAG injection log level from warning to info
- Fix all 8 test files after modular refactor (rewire imports from correct modules)
- Update AGENTS.md and README.md to reflect v1.8.0 changes
This commit is contained in:
gramps
2026-06-27 15:10:32 -07:00
parent 41a8708c0d
commit 193829b7ff
20 changed files with 457 additions and 896 deletions

View File

@@ -3,16 +3,18 @@ from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
import app
import db
from security import SESSIONS, PIN_ATTEMPTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-test.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.init_db()
return TestClient(app_module.app)
db.DB_PATH = tmp_path / "jarvischat-test.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
db.init_db()
return TestClient(app.app)
def test_guest_read_only_admin_write_blocked(tmp_path: Path):

View File

@@ -2,19 +2,24 @@ import json
import os
from pathlib import Path
import httpx
from fastapi.testclient import TestClient
import app as app_module
import app
import config
import db
import routers.chat
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-streaming.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.RATE_EVENTS.clear()
app_module.init_db()
return TestClient(app_module.app, raise_server_exceptions=False)
db.DB_PATH = tmp_path / "jarvischat-streaming.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def parse_sse_payloads(body: str) -> list[dict]:
@@ -65,11 +70,11 @@ def test_chat_stream_emits_tokens_and_done(tmp_path: Path, monkeypatch):
def stream_stub(self, method, url, json=None, timeout=None):
return _MockStreamResponse(events)
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
resp = client.post(
"/api/chat",
json={"message": "hello", "model": app_module.DEFAULT_MODEL},
json={"message": "hello", "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
@@ -92,7 +97,7 @@ def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatc
first_stream = _stream_json_lines(
[
{
"message": {"content": "I am uncertain."},
"message": {"content": "I don't have current data on that question."},
"logprobs": [{"logprob": -5.0}],
},
{"done": True, "eval_count": 2, "eval_duration": 1000000000},
@@ -118,12 +123,12 @@ def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatc
}
]
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
monkeypatch.setattr(app_module, "query_searxng", search_stub)
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
monkeypatch.setattr(routers.chat, "query_searxng", search_stub)
resp = client.post(
"/api/chat",
json={"message": "what is the latest value", "model": app_module.DEFAULT_MODEL},
json={"message": "what is the latest value", "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
@@ -153,13 +158,13 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
def stream_stub(self, method, url, json=None, timeout=None):
return _MockStreamResponse(base_stream)
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
remember_resp = client.post(
"/api/chat",
json={
"message": "remember that my favorite language is rust",
"model": app_module.DEFAULT_MODEL,
"model": config.DEFAULT_MODEL,
},
headers=headers,
)
@@ -175,7 +180,7 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
"/api/chat",
json={
"message": "forget about my favorite language",
"model": app_module.DEFAULT_MODEL,
"model": config.DEFAULT_MODEL,
},
headers=headers,
)

View File

@@ -1,19 +1,24 @@
import os
from pathlib import Path
import httpx
from fastapi.testclient import TestClient
import app as app_module
import app
import config
import db
import routers.memories
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-errors.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.RATE_EVENTS.clear()
app_module.init_db()
return TestClient(app_module.app, raise_server_exceptions=False)
db.DB_PATH = tmp_path / "jarvischat-errors.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def test_unhandled_api_exception_returns_friendly_error_with_incident_key(
@@ -28,7 +33,7 @@ def test_unhandled_api_exception_returns_friendly_error_with_incident_key(
def boom(_topic=None):
raise RuntimeError("super secret db internals")
monkeypatch.setattr(app_module, "get_all_memories", boom)
monkeypatch.setattr(routers.memories, "get_all_memories", boom)
resp = client.get("/api/memories", headers=headers)
assert resp.status_code == 500
@@ -57,11 +62,11 @@ def test_chat_stream_error_hides_internal_exception_and_emits_incident_key(
def broken_stream(*args, **kwargs):
return BrokenStreamContext()
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", broken_stream)
monkeypatch.setattr(httpx.AsyncClient, "stream", broken_stream)
resp = client.post(
"/api/chat",
json={"message": "hello", "model": app_module.DEFAULT_MODEL},
json={"message": "hello", "model": config.DEFAULT_MODEL},
headers=headers,
)

View File

@@ -3,48 +3,42 @@ from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
import app
import db
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS, is_ip_allowed
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-ip.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.RATE_EVENTS.clear()
app_module.init_db()
return TestClient(app_module.app)
db.DB_PATH = tmp_path / "jarvischat-ip.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app)
def test_ip_helper_allows_local_defaults():
assert app_module.is_ip_allowed("127.0.0.1")
assert app_module.is_ip_allowed("192.168.1.10")
assert app_module.is_ip_allowed("10.0.0.42")
assert app_module.is_ip_allowed("172.16.1.2")
assert app_module.is_ip_allowed("testclient")
assert is_ip_allowed("127.0.0.1")
assert is_ip_allowed("192.168.1.10")
assert is_ip_allowed("10.0.0.42")
assert is_ip_allowed("172.16.1.2")
assert is_ip_allowed("testclient")
def test_ip_helper_blocks_public_ip():
assert not app_module.is_ip_allowed("8.8.8.8")
assert not is_ip_allowed("8.8.8.8")
def test_middleware_blocks_disallowed_ip(tmp_path: Path):
def test_middleware_blocks_disallowed_ip(tmp_path: Path, monkeypatch):
monkeypatch.setattr(app, "get_client_ip", lambda _req: "8.8.8.8")
with make_client(tmp_path) as client:
original_get_client_ip = app_module.get_client_ip
try:
app_module.get_client_ip = lambda _req: "8.8.8.8"
resp = client.post("/api/auth/guest")
assert resp.status_code == 403
finally:
app_module.get_client_ip = original_get_client_ip
resp = client.post("/api/auth/guest")
assert resp.status_code == 403
def test_middleware_allows_local_ip(tmp_path: Path):
def test_middleware_allows_local_ip(tmp_path: Path, monkeypatch):
monkeypatch.setattr(app, "get_client_ip", lambda _req: "192.168.50.109")
with make_client(tmp_path) as client:
original_get_client_ip = app_module.get_client_ip
try:
app_module.get_client_ip = lambda _req: "192.168.50.109"
resp = client.post("/api/auth/guest")
assert resp.status_code == 200
finally:
app_module.get_client_ip = original_get_client_ip
resp = client.post("/api/auth/guest")
assert resp.status_code == 200

View File

@@ -4,24 +4,28 @@ from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
import app
import config
import db
import security
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-rate.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.RATE_EVENTS.clear()
app_module.init_db()
return TestClient(app_module.app)
db.DB_PATH = tmp_path / "jarvischat-rate.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app)
def test_stats_rate_limit_hits_429(tmp_path: Path):
old_limit = app_module.RL_STATS_PER_WINDOW
old_window = app_module.RATE_WINDOW_SECONDS
app_module.RL_STATS_PER_WINDOW = 2
app_module.RATE_WINDOW_SECONDS = 60
old_limit = security.RL_STATS_PER_WINDOW
old_window = app.RATE_WINDOW_SECONDS
security.RL_STATS_PER_WINDOW = 2
app.RATE_WINDOW_SECONDS = 60
try:
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
@@ -35,13 +39,13 @@ def test_stats_rate_limit_hits_429(tmp_path: Path):
assert r2.status_code == 200
assert r3.status_code == 429
finally:
app_module.RL_STATS_PER_WINDOW = old_limit
app_module.RATE_WINDOW_SECONDS = old_window
security.RL_STATS_PER_WINDOW = old_limit
app.RATE_WINDOW_SECONDS = old_window
def test_large_login_payload_rejected_413(tmp_path: Path):
with make_client(tmp_path) as client:
huge_pin = "1" * (app_module.BODY_LIMIT_DEFAULT_BYTES + 100)
huge_pin = "1" * (config.BODY_LIMIT_DEFAULT_BYTES + 100)
resp = client.post(
"/api/auth/login",
data=json.dumps({"pin": huge_pin}),
@@ -54,10 +58,10 @@ def test_chat_message_length_rejected_413(tmp_path: Path):
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
message = "x" * (app_module.MAX_CHAT_MESSAGE_CHARS + 1)
message = "x" * (config.MAX_CHAT_MESSAGE_CHARS + 1)
resp = client.post(
"/api/chat",
json={"message": message, "model": app_module.DEFAULT_MODEL},
json={"message": message, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 413
@@ -67,10 +71,10 @@ def test_search_query_length_rejected_413(tmp_path: Path):
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
query = "q" * (app_module.MAX_SEARCH_QUERY_CHARS + 1)
query = "q" * (config.MAX_SEARCH_QUERY_CHARS + 1)
resp = client.post(
"/api/search",
json={"query": query, "model": app_module.DEFAULT_MODEL},
json={"query": query, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 413

View File

@@ -1,17 +1,17 @@
import app as app_module
from search import sanitize_outbound_url
def test_sanitize_outbound_url_allows_http_https():
assert app_module.sanitize_outbound_url("https://example.com/path") == "https://example.com/path"
assert app_module.sanitize_outbound_url("http://example.com") == "http://example.com"
assert sanitize_outbound_url("https://example.com/path") == "https://example.com/path"
assert sanitize_outbound_url("http://example.com") == "http://example.com"
def test_sanitize_outbound_url_blocks_unsafe_schemes():
assert app_module.sanitize_outbound_url("javascript:alert(1)") == ""
assert app_module.sanitize_outbound_url("data:text/html,evil") == ""
assert app_module.sanitize_outbound_url("file:///etc/passwd") == ""
assert sanitize_outbound_url("javascript:alert(1)") == ""
assert sanitize_outbound_url("data:text/html,evil") == ""
assert sanitize_outbound_url("file:///etc/passwd") == ""
def test_sanitize_outbound_url_blocks_relative_and_empty():
assert app_module.sanitize_outbound_url("/relative/path") == ""
assert app_module.sanitize_outbound_url("") == ""
assert sanitize_outbound_url("/relative/path") == ""
assert sanitize_outbound_url("") == ""

View File

@@ -3,17 +3,19 @@ from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
import app
import db
from security import SESSIONS, PIN_ATTEMPTS
def make_admin_client(tmp_path: Path) -> tuple[TestClient, dict[str, str]]:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-settings.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.init_db()
db.DB_PATH = tmp_path / "jarvischat-settings.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
db.init_db()
client = TestClient(app_module.app)
client = TestClient(app.app)
login = client.post(
"/api/auth/login",
json={"pin": "1234"},

View File

@@ -1,19 +1,23 @@
import asyncio
import os
from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
import app
import db
from rag import build_system_prompt
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-skills.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.RATE_EVENTS.clear()
app_module.init_db()
return TestClient(app_module.app, raise_server_exceptions=False)
db.DB_PATH = tmp_path / "jarvischat-skills.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def test_guest_can_list_skills(tmp_path: Path):
@@ -71,23 +75,23 @@ def test_unknown_skill_update_is_rejected(tmp_path: Path):
def test_prompt_injection_respects_skills_enabled_setting(tmp_path: Path):
with make_client(tmp_path):
db = app_module.get_db()
conn = db.get_db()
try:
db.execute(
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
("skills_enabled", "false"),
)
db.commit()
without_skills = app_module.build_system_prompt(db, "", "hello")
conn.commit()
without_skills = asyncio.run(build_system_prompt(conn, "", "hello"))
assert "## Active Skills" not in without_skills
db.execute(
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
("skills_enabled", "true"),
)
db.commit()
with_skills = app_module.build_system_prompt(db, "", "hello")
conn.commit()
with_skills = asyncio.run(build_system_prompt(conn, "", "hello"))
assert "## Active Skills" in with_skills
assert "memory.search" in with_skills
finally:
db.close()
conn.close()