mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 21:52:20 +01:00
Compare commits
17
Commits
d5603ee575
...
af61b2d4e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af61b2d4e6 | ||
|
|
0b0656df11 | ||
|
|
9f47c5ff87 | ||
|
|
dd2d375c7b | ||
|
|
73823c878e | ||
|
|
50fedff2f2 | ||
|
|
66c25cbc2f | ||
|
|
09ec880c06 | ||
|
|
5e16126bde | ||
|
|
c01034f9cb | ||
|
|
8adca3a924 | ||
|
|
59fc6604be | ||
|
|
e98567c2b9 | ||
|
|
f34ae6b965 | ||
|
|
1ef50279fb | ||
|
|
c0d8c4de3e | ||
|
|
5deea5664e |
@@ -191,6 +191,8 @@ def _windows_bash_fallbacks() -> List[str]:
|
|||||||
base = os.environ.get(env_name)
|
base = os.environ.get(env_name)
|
||||||
if base:
|
if base:
|
||||||
roots.append(ntpath.join(base, "Git"))
|
roots.append(ntpath.join(base, "Git"))
|
||||||
|
if env_name == "LocalAppData":
|
||||||
|
roots.append(ntpath.join(base, "Programs", "Git"))
|
||||||
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
|
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
|
||||||
|
|
||||||
paths: List[str] = []
|
paths: List[str] = []
|
||||||
|
|||||||
+10
-3
@@ -25,9 +25,16 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { scroll-behavior: smooth; scroll-snap-type: y proximity; scroll-padding-top: 60px; }
|
html { scroll-behavior: smooth; scroll-padding-top: 60px; }
|
||||||
/* Each section is a full-viewport "page" with its content centered, so only
|
/* REMOVED: "scroll-snap-type: y proximity"
|
||||||
one shows at a time and the snap is obvious. */
|
The idea was: >>Each section is a full-viewport "page" with its content centered,
|
||||||
|
so only one shows at a time and the snap is obvious.<<
|
||||||
|
|
||||||
|
PROBLEM: sections easily grow taller than 100vh IRL
|
||||||
|
This cause forced jumps mid-read. It's intrusive UX.
|
||||||
|
The landing-page is not a PowerPoint presentation!
|
||||||
|
|
||||||
|
Preserved: CSS snap-points to avoid destroying code meta-data*/
|
||||||
.hero, section {
|
.hero, section {
|
||||||
scroll-snap-align: start; min-height: 100vh;
|
scroll-snap-align: start; min-height: 100vh;
|
||||||
display: flex; flex-direction: column; justify-content: center;
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
|
|||||||
+14
-2
@@ -30,14 +30,26 @@ function Fail($msg) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-WindowsBashStub($path) {
|
||||||
|
if (-not $path) { return $false }
|
||||||
|
$lowered = $path.ToLowerInvariant()
|
||||||
|
foreach ($stub in @("system32\bash.exe", "sysnative\bash.exe", "windowsapps\bash.exe")) {
|
||||||
|
if ($lowered.Contains($stub)) { return $true }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Find-GitBash {
|
function Find-GitBash {
|
||||||
$cmd = Get-Command bash -ErrorAction SilentlyContinue
|
$cmd = Get-Command bash -ErrorAction SilentlyContinue
|
||||||
if ($cmd) { return $cmd.Source }
|
if ($cmd -and -not (Test-WindowsBashStub $cmd.Source)) { return $cmd.Source }
|
||||||
|
|
||||||
$roots = @()
|
$roots = @()
|
||||||
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
|
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
|
||||||
$base = [Environment]::GetEnvironmentVariable($name)
|
$base = [Environment]::GetEnvironmentVariable($name)
|
||||||
if ($base) { $roots += (Join-Path $base "Git") }
|
if ($base) {
|
||||||
|
$roots += (Join-Path $base "Git")
|
||||||
|
if ($name -eq "LocalAppData") { $roots += (Join-Path $base "Programs\Git") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
|
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
|
||||||
|
|
||||||
|
|||||||
+54
-14
@@ -304,6 +304,7 @@ OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
|||||||
"email_ai_replies",
|
"email_ai_replies",
|
||||||
"email_calendar_extractions",
|
"email_calendar_extractions",
|
||||||
"email_urgency_alerts",
|
"email_urgency_alerts",
|
||||||
|
"sender_signatures",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -341,6 +342,55 @@ def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, co
|
|||||||
_lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}")
|
_lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sender_signatures_table(conn):
|
||||||
|
"""Create/migrate learned sender signatures to an owner-scoped cache."""
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sender_signatures (
|
||||||
|
from_address TEXT,
|
||||||
|
owner TEXT DEFAULT '',
|
||||||
|
signature_text TEXT,
|
||||||
|
sample_count INTEGER,
|
||||||
|
last_built_at TEXT NOT NULL,
|
||||||
|
model_used TEXT,
|
||||||
|
source TEXT,
|
||||||
|
PRIMARY KEY (from_address, owner)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
conn.execute(create_sql)
|
||||||
|
try:
|
||||||
|
info = conn.execute("PRAGMA table_info(sender_signatures)").fetchall()
|
||||||
|
cols = [r[1] for r in info]
|
||||||
|
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
|
||||||
|
if "owner" in cols and pk_cols == ["from_address", "owner"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute("ALTER TABLE sender_signatures RENAME TO sender_signatures__old")
|
||||||
|
conn.execute(create_sql)
|
||||||
|
old_cols = [r[1] for r in conn.execute("PRAGMA table_info(sender_signatures__old)").fetchall()]
|
||||||
|
copy_cols = [
|
||||||
|
c for c in (
|
||||||
|
"from_address",
|
||||||
|
"signature_text",
|
||||||
|
"sample_count",
|
||||||
|
"last_built_at",
|
||||||
|
"model_used",
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
if c in old_cols
|
||||||
|
]
|
||||||
|
source_owner = "COALESCE(owner, '')" if "owner" in old_cols else "''"
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT OR IGNORE INTO sender_signatures "
|
||||||
|
f"({', '.join([*copy_cols, 'owner'])}) "
|
||||||
|
f"SELECT {', '.join([*copy_cols, source_owner])} "
|
||||||
|
f"FROM sender_signatures__old"
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE sender_signatures__old")
|
||||||
|
except Exception as _mig_e:
|
||||||
|
import logging as _lg
|
||||||
|
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
|
||||||
|
|
||||||
|
|
||||||
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
||||||
"""Containment-safe extraction directory for an attachment.
|
"""Containment-safe extraction directory for an attachment.
|
||||||
|
|
||||||
@@ -559,20 +609,10 @@ def _init_scheduled_db():
|
|||||||
conn.execute("ALTER TABLE email_boundaries ADD COLUMN turns_json TEXT")
|
conn.execute("ALTER TABLE email_boundaries ADD COLUMN turns_json TEXT")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Per-sender signature cache. Populated by `learn_sender_signatures`
|
# Per-sender signature cache. Populated by `learn_sender_signatures`.
|
||||||
# action: the LLM extracts the common trailing block across N emails
|
# Message sender addresses are global, so signatures must be scoped to the
|
||||||
# from each sender; the renderer folds it consistently for every
|
# mailbox owner before `/read` returns them to the renderer.
|
||||||
# future email from that address.
|
_ensure_sender_signatures_table(conn)
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS 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.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1247,8 +1247,9 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
if sender_addr:
|
if sender_addr:
|
||||||
_rs = _c.execute(
|
_rs = _c.execute(
|
||||||
"SELECT signature_text FROM sender_signatures WHERE from_address = ?",
|
f"SELECT signature_text FROM sender_signatures "
|
||||||
(sender_addr.lower().strip(),),
|
f"WHERE from_address = ? AND {owner_clause}",
|
||||||
|
(sender_addr.lower().strip(), *owner_params),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if _rs and _rs[0]:
|
if _rs and _rs[0]:
|
||||||
cached_sender_sig = _rs[0]
|
cached_sender_sig = _rs[0]
|
||||||
|
|||||||
+27
-5
@@ -123,6 +123,21 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
|||||||
return cleared_users
|
return cleared_users
|
||||||
|
|
||||||
|
|
||||||
|
def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint_ids) -> bool:
|
||||||
|
"""Whether the global default chat endpoint should be (re)assigned.
|
||||||
|
|
||||||
|
True when nothing is configured yet, or the configured default no longer
|
||||||
|
resolves to an enabled endpoint (e.g. the user disabled it). Without the
|
||||||
|
second case, adding a new endpoint after disabling the previous default
|
||||||
|
leaves `default_endpoint_id` pointing at the disabled endpoint, so features
|
||||||
|
that read the raw setting (Memory → Tidy) fail with "No default model
|
||||||
|
configured" even though an enabled endpoint exists. See #3586.
|
||||||
|
"""
|
||||||
|
if not current_default_id:
|
||||||
|
return True
|
||||||
|
return current_default_id not in enabled_endpoint_ids
|
||||||
|
|
||||||
|
|
||||||
# Loopback hosts a user might type for a local model server (LM Studio,
|
# Loopback hosts a user might type for a local model server (LM Studio,
|
||||||
# llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the
|
# llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the
|
||||||
# host the server actually runs on.
|
# host the server actually runs on.
|
||||||
@@ -1727,12 +1742,19 @@ def setup_model_routes(model_discovery):
|
|||||||
)
|
)
|
||||||
db.add(ep)
|
db.add(ep)
|
||||||
db.commit()
|
db.commit()
|
||||||
# Auto-set as default chat endpoint if none configured yet. Seed
|
# Auto-set as default chat endpoint when none is usable yet — either
|
||||||
# the first CHAT model (not raw model_ids[0]) so we don't pin the
|
# nothing is configured, or the configured default points at an
|
||||||
# global default to an embedding/tts/etc. entry a provider happens
|
# endpoint that is now missing/disabled (#3586). Seed the first CHAT
|
||||||
# to list first.
|
# model (not raw model_ids[0]) so we don't pin the global default to
|
||||||
|
# an embedding/tts/etc. entry a provider happens to list first.
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
if not settings.get("default_endpoint_id"):
|
enabled_ids = {
|
||||||
|
e.id
|
||||||
|
for e in db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True # noqa: E712
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
if _default_endpoint_needs_assignment(settings.get("default_endpoint_id") or "", enabled_ids):
|
||||||
from src.endpoint_resolver import _first_chat_model
|
from src.endpoint_resolver import _first_chat_model
|
||||||
settings["default_endpoint_id"] = ep.id
|
settings["default_endpoint_id"] = ep.id
|
||||||
settings["default_model"] = _first_chat_model(model_ids) or ""
|
settings["default_model"] = _first_chat_model(model_ids) or ""
|
||||||
|
|||||||
+10
-7
@@ -809,14 +809,14 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
import email as _email_mod
|
import email as _email_mod
|
||||||
import asyncio as _aio
|
import asyncio as _aio
|
||||||
from datetime import datetime as _dt, timedelta as _td
|
from datetime import datetime as _dt, timedelta as _td
|
||||||
from routes.email_helpers import _imap_connect, SCHEDULED_DB
|
from routes.email_helpers import _email_cache_owner_clause, _imap_connect, SCHEDULED_DB
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
|
||||||
# 1. Pull recent UIDs + From headers cheaply (header-only fetch).
|
# 1. Pull recent UIDs + From headers cheaply (header-only fetch).
|
||||||
def _pull_headers():
|
def _pull_headers():
|
||||||
results = []
|
results = []
|
||||||
conn = _imap_connect(None)
|
conn = _imap_connect(None, owner=owner)
|
||||||
try:
|
try:
|
||||||
conn.select("INBOX", readonly=True)
|
conn.select("INBOX", readonly=True)
|
||||||
status, data = conn.search(None, "ALL")
|
status, data = conn.search(None, "ALL")
|
||||||
@@ -868,9 +868,11 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
# 3. Eligibility: ≥3 emails AND (no cache OR cache > 30 days old).
|
# 3. Eligibility: ≥3 emails AND (no cache OR cache > 30 days old).
|
||||||
try:
|
try:
|
||||||
conn = _sql3.connect(SCHEDULED_DB)
|
conn = _sql3.connect(SCHEDULED_DB)
|
||||||
|
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||||
cached = {
|
cached = {
|
||||||
r[0]: r[1] for r in conn.execute(
|
r[0]: r[1] for r in conn.execute(
|
||||||
"SELECT from_address, last_built_at FROM sender_signatures"
|
f"SELECT from_address, last_built_at FROM sender_signatures WHERE {owner_clause}",
|
||||||
|
owner_params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -901,7 +903,7 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
|
|
||||||
def _fetch_bodies(_msgs):
|
def _fetch_bodies(_msgs):
|
||||||
bodies = []
|
bodies = []
|
||||||
conn2 = _imap_connect(None)
|
conn2 = _imap_connect(None, owner=owner)
|
||||||
try:
|
try:
|
||||||
conn2.select("INBOX", readonly=True)
|
conn2.select("INBOX", readonly=True)
|
||||||
for mm in _msgs:
|
for mm in _msgs:
|
||||||
@@ -978,11 +980,12 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
conn = _sql3.connect(SCHEDULED_DB)
|
conn = _sql3.connect(SCHEDULED_DB)
|
||||||
|
owner_value = (owner or "").strip()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO sender_signatures "
|
"INSERT OR REPLACE INTO sender_signatures "
|
||||||
"(from_address, signature_text, sample_count, last_built_at, model_used, source) "
|
"(from_address, owner, signature_text, sample_count, last_built_at, model_used, source) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(addr, cached_sig, len(bodies), _dt.utcnow().isoformat(), model, "llm"),
|
(addr, owner_value, cached_sig, len(bodies), _dt.utcnow().isoformat(), model, "llm"),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
+11
-1
@@ -12,6 +12,8 @@ tunnel / reverse proxy. Scrubbing is deep (recurses nested dicts/lists) and keye
|
|||||||
on secret-shaped names.
|
on secret-shaped names.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
_SECRET_KEY_PATTERNS = (
|
_SECRET_KEY_PATTERNS = (
|
||||||
"_api_key", "_apikey", "_password", "_passwd", "_pass", "_pwd",
|
"_api_key", "_apikey", "_password", "_passwd", "_pass", "_pwd",
|
||||||
"_secret", "_client_secret", "_token", "_access_token", "_refresh_token",
|
"_secret", "_client_secret", "_token", "_access_token", "_refresh_token",
|
||||||
@@ -26,8 +28,16 @@ _SENSITIVE_KEY_EXACT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_key_name(name: str) -> str:
|
||||||
|
"""Normalize common JS-style key names so secret matching is style-agnostic."""
|
||||||
|
n = (name or "").replace("-", "_")
|
||||||
|
n = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", n)
|
||||||
|
n = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", n)
|
||||||
|
return n.lower()
|
||||||
|
|
||||||
|
|
||||||
def is_secret_key(name: str) -> bool:
|
def is_secret_key(name: str) -> bool:
|
||||||
n = (name or "").lower()
|
n = _canonical_key_name(name)
|
||||||
if n in _SECRET_KEY_ALLOW:
|
if n in _SECRET_KEY_ALLOW:
|
||||||
return False
|
return False
|
||||||
if n in _SENSITIVE_KEY_EXACT:
|
if n in _SENSITIVE_KEY_EXACT:
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ export const ERROR_PATTERNS = [
|
|||||||
{ label: 'Repair kernel package', action: () => {
|
{ label: 'Repair kernel package', action: () => {
|
||||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||||
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages kernels<0.15`);
|
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages "kernels<0.15"`);
|
||||||
}},
|
}},
|
||||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
from src.builtin_actions import action_learn_sender_signatures
|
||||||
|
|
||||||
class FakeImap:
|
class FakeImap:
|
||||||
|
def __init__(self, owner=""):
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
def select(self, *_args, **_kwargs):
|
def select(self, *_args, **_kwargs):
|
||||||
return "OK", []
|
return "OK", []
|
||||||
|
|
||||||
@@ -119,13 +122,20 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
calls, _fallback_calls = _resolver_spy(monkeypatch, utility_result=("", "", {}), default_result=("", "", {}))
|
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")
|
message, ok = await action_learn_sender_signatures("alice")
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert message == "No LLM endpoint available"
|
assert message == "No LLM endpoint available"
|
||||||
assert calls == [("utility", "alice"), ("default", "alice")]
|
assert calls == [("utility", "alice"), ("default", "alice")]
|
||||||
|
assert imap_owners == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -117,6 +119,71 @@ def test_email_ai_cache_tables_are_owner_scoped_and_migrate_legacy_rows(tmp_path
|
|||||||
conn.close()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_ai_reply_cache_lookup_is_owner_scoped(tmp_path, monkeypatch):
|
async def test_ai_reply_cache_lookup_is_owner_scoped(tmp_path, monkeypatch):
|
||||||
import routes.email_helpers as email_helpers
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_scheduled_email_routes_are_owner_scoped(tmp_path, monkeypatch):
|
async def test_scheduled_email_routes_are_owner_scoped(tmp_path, monkeypatch):
|
||||||
import routes.email_helpers as email_helpers
|
import routes.email_helpers as email_helpers
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
|
|||||||
_endpoint_settings_using_endpoint,
|
_endpoint_settings_using_endpoint,
|
||||||
_clear_endpoint_settings_for_endpoint,
|
_clear_endpoint_settings_for_endpoint,
|
||||||
_clear_user_pref_endpoint_refs,
|
_clear_user_pref_endpoint_refs,
|
||||||
|
_default_endpoint_needs_assignment,
|
||||||
_PROVIDER_CURATED,
|
_PROVIDER_CURATED,
|
||||||
)
|
)
|
||||||
from src.llm_core import ANTHROPIC_MODELS
|
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"] == []
|
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 ──
|
# ── _match_provider_curated ──
|
||||||
|
|
||||||
class TestMatchProviderCurated:
|
class TestMatchProviderCurated:
|
||||||
@@ -966,16 +987,21 @@ def _create_form_kwargs(**overrides):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
def _patch_create_deps(monkeypatch, db):
|
def _patch_create_deps(monkeypatch, db, settings=None):
|
||||||
import src.auth_helpers as auth_helpers
|
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, "SessionLocal", lambda: db)
|
||||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RecordingEndpoint)
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RecordingEndpoint)
|
||||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda b: b)
|
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, "_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(endpoint_resolver, "resolve_url", lambda u: u)
|
||||||
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
|
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
|
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"
|
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):
|
def test_post_same_base_url_same_api_key_still_dedupes(monkeypatch):
|
||||||
existing = _make_endpoint(
|
existing = _make_endpoint(
|
||||||
base_url="https://api.example.test/v1",
|
base_url="https://api.example.test/v1",
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ def test_find_bash_checks_local_app_data_git_install(monkeypatch):
|
|||||||
assert platform_compat.find_bash() == expected
|
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):
|
def test_find_bash_skips_windows_wsl_stub(monkeypatch):
|
||||||
_reset_bash_cache(monkeypatch)
|
_reset_bash_cache(monkeypatch)
|
||||||
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
|
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
|
||||||
|
|||||||
@@ -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[1]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -40,7 +40,8 @@ def test_secret_in_list_of_dicts_blanked():
|
|||||||
|
|
||||||
def test_non_secret_keys_preserved():
|
def test_non_secret_keys_preserved():
|
||||||
s = {"keybinds": {"send": "Enter"}, "theme": "dark", "image_model": "x",
|
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
|
assert scrub_settings(s) == s # untouched
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +72,23 @@ def test_exact_name_matches():
|
|||||||
assert all(v == "" for v in out.values()), out
|
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():
|
def test_non_object_settings_return_empty_mapping():
|
||||||
assert scrub_settings(["not", "settings"]) == {}
|
assert scrub_settings(["not", "settings"]) == {}
|
||||||
assert scrub_settings("not settings") == {}
|
assert scrub_settings("not settings") == {}
|
||||||
|
|||||||
Reference in New Issue
Block a user