mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 06:02:30 +01:00
Compare commits
9
Commits
4901a96591
...
6efc07c5fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efc07c5fc | ||
|
|
bc771fbc1e | ||
|
|
21c7bf802e | ||
|
|
e0fd68160f | ||
|
|
def3483032 | ||
|
|
667f9e4cae | ||
|
|
7b25b6dbdc | ||
|
|
7a1c7395c0 | ||
|
|
a8d215a390 |
@@ -1068,6 +1068,28 @@ def _scheduled_poll_once() -> dict:
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
sid = r[0]
|
sid = r[0]
|
||||||
try:
|
try:
|
||||||
|
# Atomically claim this row before doing any work. Two
|
||||||
|
# pollers can race here (the in-process asyncio task and an
|
||||||
|
# externally cron-driven `odysseus-mail poll-scheduled`, or
|
||||||
|
# an admin running the CLI manually alongside the in-process
|
||||||
|
# one despite the ODYSSEUS_INPROCESS_POLLERS=0 guidance) -
|
||||||
|
# both can SELECT the same 'pending' row before either has
|
||||||
|
# updated its status. The UPDATE...WHERE status='pending' is
|
||||||
|
# the atomicity boundary: only the poller whose UPDATE
|
||||||
|
# actually changes a row (rowcount == 1) proceeds to send;
|
||||||
|
# a loser sees rowcount == 0 and skips it instead of sending
|
||||||
|
# a duplicate.
|
||||||
|
claim_conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
|
claim_cur = claim_conn.execute(
|
||||||
|
"UPDATE scheduled_emails SET status='sending' WHERE id=? AND status='pending'",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
claim_conn.commit()
|
||||||
|
claimed = claim_cur.rowcount == 1
|
||||||
|
claim_conn.close()
|
||||||
|
if not claimed:
|
||||||
|
continue
|
||||||
|
|
||||||
attachments = json.loads(r[8] or "[]")
|
attachments = json.loads(r[8] or "[]")
|
||||||
row_account_id = r[9] if len(r) > 9 else None
|
row_account_id = r[9] if len(r) > 9 else None
|
||||||
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
||||||
|
|||||||
+20
-17
@@ -923,29 +923,32 @@ def _resolve_send_config(account_id: str | None = None, owner: str = "") -> dict
|
|||||||
|
|
||||||
|
|
||||||
def _store_email_flag(conn, uid: str, flag: str, add: bool = True) -> bool:
|
def _store_email_flag(conn, uid: str, flag: str, add: bool = True) -> bool:
|
||||||
|
# imaplib's plain store() takes a message SEQUENCE NUMBER, not a UID, so the
|
||||||
|
# old `else` fallback flagged whichever message happened to occupy sequence
|
||||||
|
# position == the UID value. When the UID isn't present, fail safe (callers
|
||||||
|
# surface "Email not found") rather than touch an unrelated message.
|
||||||
|
if not _uid_exists(conn, uid):
|
||||||
|
return False
|
||||||
op = "+FLAGS" if add else "-FLAGS"
|
op = "+FLAGS" if add else "-FLAGS"
|
||||||
if _uid_exists(conn, uid):
|
status, _ = conn.uid("STORE", _uid_bytes(uid), op, flag)
|
||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), op, flag)
|
|
||||||
else:
|
|
||||||
status, _ = conn.store(_uid_bytes(uid), op, flag)
|
|
||||||
return status == "OK"
|
return status == "OK"
|
||||||
|
|
||||||
|
|
||||||
def _move_email_message(conn, uid: str, dest: str, role: str = "") -> bool:
|
def _move_email_message(conn, uid: str, dest: str, role: str = "") -> bool:
|
||||||
dest = _resolve_mail_folder(conn, dest, role or _folder_role_from_name(dest))
|
dest = _resolve_mail_folder(conn, dest, role or _folder_role_from_name(dest))
|
||||||
if _uid_exists(conn, uid):
|
# copy()/store() are SEQUENCE-NUMBER commands; using them with a UID (the old
|
||||||
status, _ = conn.uid("MOVE", _uid_bytes(uid), _q(dest))
|
# `else` branch) copied + \Deleted-flagged the wrong message and then
|
||||||
if status == "OK":
|
# expunge() permanently removed it. There is no valid case where treating a
|
||||||
return True
|
# UID as a sequence number is correct, so fail safe when the UID is absent.
|
||||||
status, _ = conn.uid("COPY", _uid_bytes(uid), _q(dest))
|
if not _uid_exists(conn, uid):
|
||||||
if status != "OK":
|
return False
|
||||||
return False
|
status, _ = conn.uid("MOVE", _uid_bytes(uid), _q(dest))
|
||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
if status == "OK":
|
||||||
else:
|
return True
|
||||||
status, _ = conn.copy(_uid_bytes(uid), _q(dest))
|
status, _ = conn.uid("COPY", _uid_bytes(uid), _q(dest))
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
status, _ = conn.store(_uid_bytes(uid), "+FLAGS", "\\Deleted")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
||||||
if status == "OK":
|
if status == "OK":
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
return True
|
return True
|
||||||
|
|||||||
+23
-9
@@ -133,18 +133,32 @@ def cmd_list(args):
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
entries = []
|
entries = []
|
||||||
for p in sorted(_BACKUP_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
for p in _BACKUP_DIR.iterdir():
|
||||||
if not p.is_file():
|
entry = _backup_entry(p)
|
||||||
continue
|
if entry is not None:
|
||||||
entries.append({
|
entries.append(entry)
|
||||||
"path": str(p),
|
entries.sort(key=lambda entry: entry["_mtime"], reverse=True)
|
||||||
"name": p.name,
|
for entry in entries:
|
||||||
"bytes": p.stat().st_size,
|
entry.pop("_mtime", None)
|
||||||
"modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
|
|
||||||
})
|
|
||||||
emit(entries, args)
|
emit(entries, args)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_entry(p):
|
||||||
|
try:
|
||||||
|
if not p.is_file():
|
||||||
|
return None
|
||||||
|
st = p.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"path": str(p),
|
||||||
|
"name": p.name,
|
||||||
|
"bytes": st.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||||
|
"_mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args):
|
def cmd_verify(args):
|
||||||
"""Open the tarball read-only and walk its members — confirms
|
"""Open the tarball read-only and walk its members — confirms
|
||||||
integrity without extracting anything."""
|
integrity without extracting anything."""
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def cmd_add(args):
|
|||||||
# add_entry doesn't save by default — the call in chat does it
|
# add_entry doesn't save by default — the call in chat does it
|
||||||
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
||||||
all_entries = _manager().load_all()
|
all_entries = _manager().load_all()
|
||||||
if not any(e.get("id") == entry.get("id") for e in all_entries):
|
if not any(isinstance(e, dict) and e.get("id") == entry.get("id") for e in all_entries):
|
||||||
all_entries.append(entry)
|
all_entries.append(entry)
|
||||||
_manager().save(all_entries)
|
_manager().save(all_entries)
|
||||||
emit(entry, args)
|
emit(entry, args)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def is_prequantized(model):
|
|||||||
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
||||||
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
||||||
or any(x in text for x in ("awq", "gptq", "mlx"))
|
or any(x in text for x in ("awq", "gptq", "mlx"))
|
||||||
or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
or any(isinstance(q, str) and q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ def params_b(model):
|
|||||||
return raw / 1_000_000_000.0
|
return raw / 1_000_000_000.0
|
||||||
|
|
||||||
pc = model.get("parameter_count", "")
|
pc = model.get("parameter_count", "")
|
||||||
if pc:
|
if isinstance(pc, str) and pc:
|
||||||
pc = pc.strip().upper()
|
pc = pc.strip().upper()
|
||||||
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
||||||
if m:
|
if m:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TTSService:
|
|||||||
if provider == "local":
|
if provider == "local":
|
||||||
kokoro = self._get_kokoro()
|
kokoro = self._get_kokoro()
|
||||||
return kokoro is not None and kokoro.available
|
return kokoro is not None and kokoro.available
|
||||||
if provider.startswith("endpoint:"):
|
if isinstance(provider, str) and provider.startswith("endpoint:"):
|
||||||
return True # assume reachable; errors surface at synthesis time
|
return True # assume reachable; errors surface at synthesis time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -96,7 +96,9 @@ class DbTokenStorage:
|
|||||||
try:
|
try:
|
||||||
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
|
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
|
||||||
if srv and srv.oauth_tokens:
|
if srv and srv.oauth_tokens:
|
||||||
return json.loads(srv.oauth_tokens)
|
parsed = json.loads(srv.oauth_tokens)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
return {}
|
return {}
|
||||||
@@ -111,6 +113,8 @@ class DbTokenStorage:
|
|||||||
if srv is None:
|
if srv is None:
|
||||||
return
|
return
|
||||||
data = json.loads(srv.oauth_tokens) if srv.oauth_tokens else {}
|
data = json.loads(srv.oauth_tokens) if srv.oauth_tokens else {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
data[key] = value
|
data[key] = value
|
||||||
srv.oauth_tokens = json.dumps(data)
|
srv.oauth_tokens = json.dumps(data)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+8
-4
@@ -356,7 +356,11 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
||||||
if owner and task.owner and task.owner != owner:
|
# Strict ownership: the old `task.owner and task.owner != owner`
|
||||||
|
# skipped the check on an owner-less task (created in no-login mode
|
||||||
|
# or before the legacy-owner sweep), letting any authenticated user
|
||||||
|
# reach it. `list` already scopes to an exact owner match.
|
||||||
|
if owner and task.owner != owner:
|
||||||
return {"error": "Access denied", "exit_code": 1}
|
return {"error": "Access denied", "exit_code": 1}
|
||||||
|
|
||||||
changed = []
|
changed = []
|
||||||
@@ -402,7 +406,7 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
||||||
if owner and task.owner and task.owner != owner:
|
if owner and task.owner != owner:
|
||||||
return {"error": "Access denied", "exit_code": 1}
|
return {"error": "Access denied", "exit_code": 1}
|
||||||
name = task.name
|
name = task.name
|
||||||
db.delete(task)
|
db.delete(task)
|
||||||
@@ -416,7 +420,7 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
||||||
if owner and task.owner and task.owner != owner:
|
if owner and task.owner != owner:
|
||||||
return {"error": "Access denied", "exit_code": 1}
|
return {"error": "Access denied", "exit_code": 1}
|
||||||
|
|
||||||
if action == "pause":
|
if action == "pause":
|
||||||
@@ -437,7 +441,7 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
return {"error": f"Task {task_id} not found", "exit_code": 1}
|
||||||
if owner and task.owner and task.owner != owner:
|
if owner and task.owner != owner:
|
||||||
return {"error": "Access denied", "exit_code": 1}
|
return {"error": "Access denied", "exit_code": 1}
|
||||||
|
|
||||||
from src.event_bus import get_task_scheduler
|
from src.event_bus import get_task_scheduler
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const _defaultKeybinds = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function _matchesCombo(e, combo, isMac = IS_MAC) {
|
export function _matchesCombo(e, combo, isMac = IS_MAC) {
|
||||||
if (!combo) return false;
|
if (typeof combo !== 'string' || !combo) return false;
|
||||||
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
|
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
|
||||||
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
|
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
|
||||||
if (isAltGrEvent(e, isMac)) return false;
|
if (isAltGrEvent(e, isMac)) return false;
|
||||||
|
|||||||
@@ -25,6 +25,46 @@ def _verify_args(path: Path):
|
|||||||
return SimpleNamespace(path=str(path), pretty=False)
|
return SimpleNamespace(path=str(path), pretty=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_entry_skips_files_that_disappear():
|
||||||
|
backup = _load_backup_cli()
|
||||||
|
|
||||||
|
class Vanished:
|
||||||
|
name = "gone.tar.gz"
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stat(self):
|
||||||
|
raise FileNotFoundError("gone")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "backups/gone.tar.gz"
|
||||||
|
|
||||||
|
assert backup._backup_entry(Vanished()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_list_sorts_by_captured_mtime(monkeypatch):
|
||||||
|
backup = _load_backup_cli()
|
||||||
|
first = SimpleNamespace(name="older.tar.gz")
|
||||||
|
second = SimpleNamespace(name="newer.tar.gz")
|
||||||
|
monkeypatch.setattr(backup, "_BACKUP_DIR", SimpleNamespace(
|
||||||
|
is_dir=lambda: True,
|
||||||
|
iterdir=lambda: [first, second],
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(backup, "_backup_entry", lambda p: {
|
||||||
|
"name": p.name,
|
||||||
|
"modified": "2026-10-25T01:45:00" if p is first else "2026-10-25T01:15:00",
|
||||||
|
"_mtime": 100 if p is first else 200,
|
||||||
|
})
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(backup, "emit", lambda payload, args: seen.append(payload))
|
||||||
|
|
||||||
|
backup.cmd_list(SimpleNamespace(pretty=False))
|
||||||
|
|
||||||
|
assert [entry["name"] for entry in seen[0]] == ["newer.tar.gz", "older.tar.gz"]
|
||||||
|
assert all("_mtime" not in entry for entry in seen[0])
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch):
|
def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch):
|
||||||
backup = _load_backup_cli()
|
backup = _load_backup_cli()
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Email move/flag must never fall back to sequence-number IMAP ops (#1874 sibling).
|
||||||
|
|
||||||
|
`imaplib`'s plain `store()` / `copy()` operate on message SEQUENCE NUMBERS, not
|
||||||
|
UIDs. `_store_email_flag` / `_move_email_message` (used by the archive / delete /
|
||||||
|
move / mark endpoints) had an `else` fallback that, when `_uid_exists` returned
|
||||||
|
False, ran `conn.store(uid, …)` / `conn.copy(uid, …)` + `conn.expunge()` — i.e.
|
||||||
|
it flagged/copied whichever message occupied sequence position == the UID value
|
||||||
|
and then permanently expunged it. A stale cached UID (or a server whose UID
|
||||||
|
probe misbehaves) therefore deleted an unrelated email.
|
||||||
|
|
||||||
|
The fix fails safe: when the UID isn't present, return False (callers surface
|
||||||
|
"Email not found") and never touch a message by sequence number.
|
||||||
|
|
||||||
|
This is distinct from #1874, which fixes the auto-spam poller's `_imap_move` in
|
||||||
|
`routes/email_helpers.py`; this covers the user-facing endpoints in
|
||||||
|
`routes/email_routes.py`.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routes import email_routes
|
||||||
|
from routes.email_routes import _store_email_flag, _move_email_message
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
"""Records IMAP calls. `uid_present` controls the FETCH-UID probe result.
|
||||||
|
|
||||||
|
The sequence-number commands (store/copy/expunge) raise if ever called —
|
||||||
|
the whole point of the fix is that they must not be reached.
|
||||||
|
"""
|
||||||
|
def __init__(self, uid_present, uid_move_ok=True):
|
||||||
|
self.uid_present = uid_present
|
||||||
|
self.uid_move_ok = uid_move_ok
|
||||||
|
self.uid_calls = []
|
||||||
|
self.seqno_calls = []
|
||||||
|
|
||||||
|
def uid(self, command, *args):
|
||||||
|
self.uid_calls.append((command.upper(), args))
|
||||||
|
cmd = command.upper()
|
||||||
|
if cmd == "FETCH":
|
||||||
|
return ("OK", [b"1 (UID 5031)"] if self.uid_present else [])
|
||||||
|
if cmd == "MOVE":
|
||||||
|
return ("OK" if self.uid_move_ok else "NO", [b""])
|
||||||
|
if cmd in ("COPY", "STORE"):
|
||||||
|
return ("OK", [b""])
|
||||||
|
return ("OK", [b""])
|
||||||
|
|
||||||
|
# Sequence-number APIs — must never be used with a UID.
|
||||||
|
def store(self, *a):
|
||||||
|
self.seqno_calls.append(("store", a)); return ("OK", [b""])
|
||||||
|
|
||||||
|
def copy(self, *a):
|
||||||
|
self.seqno_calls.append(("copy", a)); return ("OK", [b""])
|
||||||
|
|
||||||
|
def expunge(self, *a):
|
||||||
|
self.seqno_calls.append(("expunge", a)); return ("OK", [b""])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_folder_resolution(monkeypatch):
|
||||||
|
# _move_email_message resolves the destination folder via the connection;
|
||||||
|
# short-circuit it so the test focuses on the UID-vs-seqno behaviour.
|
||||||
|
monkeypatch.setattr(email_routes, "_resolve_mail_folder", lambda conn, dest, role="": dest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_flag_missing_uid_fails_safe():
|
||||||
|
conn = _FakeConn(uid_present=False)
|
||||||
|
assert _store_email_flag(conn, "5031", "\\Deleted", add=True) is False
|
||||||
|
assert conn.seqno_calls == [] # never touched a message by sequence number
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_missing_uid_fails_safe():
|
||||||
|
conn = _FakeConn(uid_present=False)
|
||||||
|
assert _move_email_message(conn, "5031", "Trash", role="trash") is False
|
||||||
|
assert conn.seqno_calls == [] # no copy/store/expunge on a phantom seqno
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_flag_present_uid_uses_uid_store():
|
||||||
|
conn = _FakeConn(uid_present=True)
|
||||||
|
assert _store_email_flag(conn, "5031", "\\Seen", add=True) is True
|
||||||
|
assert any(c[0] == "STORE" for c in conn.uid_calls)
|
||||||
|
assert conn.seqno_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_present_uid_uses_uid_move():
|
||||||
|
conn = _FakeConn(uid_present=True, uid_move_ok=True)
|
||||||
|
assert _move_email_message(conn, "5031", "Archive", role="archive") is True
|
||||||
|
assert any(c[0] == "MOVE" for c in conn.uid_calls)
|
||||||
|
assert conn.seqno_calls == []
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Harden hwfit model-catalog parsing against non-string field values.
|
||||||
|
|
||||||
|
`params_b` and `is_prequantized` read free-form fields straight off the HF
|
||||||
|
catalog JSON. `parameter_count` is normally a string like "7B" and
|
||||||
|
`quantization` a string like "FP8", but a catalog row can carry a non-string
|
||||||
|
(e.g. an integer parameter_count, or a null/number quantization). The code
|
||||||
|
called `pc.strip()` / `q.startswith(...)` directly, so one such row raised
|
||||||
|
AttributeError and aborted the whole ranking pass (params_b/is_prequantized
|
||||||
|
run for every model). Non-strings are now treated as unknown.
|
||||||
|
"""
|
||||||
|
from services.hwfit.models import params_b, is_prequantized
|
||||||
|
|
||||||
|
|
||||||
|
def test_params_b_nonstring_count_does_not_raise():
|
||||||
|
assert params_b({"parameter_count": 7}) == 0.0
|
||||||
|
assert params_b({"parameter_count": ["7B"]}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_params_b_valid_count_still_parses():
|
||||||
|
assert params_b({"parameter_count": "7B"}) == 7.0
|
||||||
|
assert params_b({"parameters_raw": 7_000_000_000}) == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_prequantized_nonstring_quantization_does_not_raise():
|
||||||
|
assert is_prequantized({"quantization": 8}) is False
|
||||||
|
assert is_prequantized({"name": "plain-model", "quantization": 123}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_prequantized_still_detects_real_markers():
|
||||||
|
assert is_prequantized({"name": "some-model-awq"}) is True
|
||||||
|
assert is_prequantized({"quantization": "FP8-Mixed"}) is True
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""manage_tasks mutations must fail closed on owner-less / cross-owner tasks.
|
||||||
|
|
||||||
|
The edit/delete/pause/run actions of ``do_manage_tasks`` previously gated with
|
||||||
|
``if owner and task.owner and task.owner != owner``. The middle term made the
|
||||||
|
check a no-op whenever the task had no owner — the state a scheduled task is in
|
||||||
|
when it was created in no-login mode (or via the localhost middleware bypass)
|
||||||
|
before the periodic legacy-owner sweep reassigns it to the admin user. So any
|
||||||
|
authenticated user's agent could edit, delete, pause, or *run* another tenant's
|
||||||
|
owner-less task. The sibling ``list`` action already scopes with an exact
|
||||||
|
``ScheduledTask.owner == owner`` filter, so the mutators were strictly more
|
||||||
|
permissive than the reader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from tests.helpers.import_state import clear_fake_database_modules
|
||||||
|
|
||||||
|
clear_fake_database_modules()
|
||||||
|
|
||||||
|
import core.database as cdb
|
||||||
|
from core.database import ScheduledTask
|
||||||
|
from src.tools.system import do_manage_tasks
|
||||||
|
|
||||||
|
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_ENGINE = create_engine(
|
||||||
|
f"sqlite:///{_TMPDB.name}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
|
cdb.Base.metadata.create_all(_ENGINE)
|
||||||
|
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||||
|
# do_manage_tasks does `from core.database import SessionLocal` at call time,
|
||||||
|
# so patching the module attribute is enough to point it at the temp DB.
|
||||||
|
cdb.SessionLocal = _TS
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(task_id, owner):
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
db.add(ScheduledTask(
|
||||||
|
id=task_id, owner=owner, name=task_id, prompt="original",
|
||||||
|
task_type="llm", trigger_type="webhook", status="active",
|
||||||
|
output_target="session",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(task_id):
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_denied_on_ownerless_task_for_authenticated_user():
|
||||||
|
_seed("ownerless-edit", None)
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "edit", "task_id": "ownerless-edit", "prompt": "pwned"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||||
|
assert _get("ownerless-edit").prompt == "original"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_denied_on_ownerless_task_for_authenticated_user():
|
||||||
|
_seed("ownerless-del", None)
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "delete", "task_id": "ownerless-del"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||||
|
assert _get("ownerless-del") is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_denied_on_ownerless_task_for_authenticated_user():
|
||||||
|
_seed("ownerless-pause", None)
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "pause", "task_id": "ownerless-pause"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||||
|
assert _get("ownerless-pause").status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_denied_on_ownerless_task_for_authenticated_user():
|
||||||
|
_seed("ownerless-run", None)
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "run", "task_id": "ownerless-run"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_denied_on_other_owners_task():
|
||||||
|
_seed("bob-task", "bob")
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "edit", "task_id": "bob-task", "prompt": "pwned"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||||
|
assert _get("bob-task").prompt == "original"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_allowed_for_matching_owner():
|
||||||
|
_seed("alice-task", "alice")
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "edit", "task_id": "alice-task", "prompt": "updated"}),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 0
|
||||||
|
assert _get("alice-task").prompt == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_allowed_in_no_login_mode():
|
||||||
|
# owner is None when auth is disabled — single-user mode keeps full access
|
||||||
|
# to shared (owner-less) tasks, exactly as `list` returns them unfiltered.
|
||||||
|
_seed("shared-task", None)
|
||||||
|
out = await do_manage_tasks(
|
||||||
|
json.dumps({"action": "edit", "task_id": "shared-task", "prompt": "updated"}),
|
||||||
|
owner=None,
|
||||||
|
)
|
||||||
|
assert out["exit_code"] == 0
|
||||||
|
assert _get("shared-task").prompt == "updated"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Pin _matchesCombo (static/js/keyboard-shortcuts.js) against a non-string
|
||||||
|
keybind. Driven through `node --input-type=module` (same approach as
|
||||||
|
tests/test_markdown_table_row_js.py); skips when `node` is missing.
|
||||||
|
|
||||||
|
Regression: keybinds are merged from the server response of
|
||||||
|
`/api/auth/settings` (`{ ..._defaultKeybinds, ...s.keybinds }`). A corrupt
|
||||||
|
or malformed `keybinds` value (e.g. a number instead of "ctrl+k") reached
|
||||||
|
`combo.split('+')` and threw "combo.split is not a function", breaking the
|
||||||
|
whole keydown handler. The guard treats any non-string combo as "no match".
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_MOD = _REPO / "static" / "js" / "keyboard-shortcuts.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
_EVENT = "{key:'k',ctrlKey:false,altKey:false,shiftKey:false,metaKey:false}"
|
||||||
|
|
||||||
|
|
||||||
|
def _match(combo_js):
|
||||||
|
js = f"""
|
||||||
|
import {{ _matchesCombo }} from '{_MOD.as_posix()}';
|
||||||
|
console.log(JSON.stringify(_matchesCombo({_EVENT}, {combo_js})));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_non_string_combo_is_no_match():
|
||||||
|
assert _match("123") is False
|
||||||
|
assert _match("{}") is False
|
||||||
|
assert _match("null") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_matching_combo_still_fires():
|
||||||
|
assert _match("'k'") is True
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from src import mcp_oauth
|
from src import mcp_oauth
|
||||||
|
|
||||||
|
|
||||||
@@ -79,3 +80,53 @@ def test_db_token_storage_round_trip():
|
|||||||
t = asyncio.run(go())
|
t = asyncio.run(go())
|
||||||
assert t.access_token == "abc"
|
assert t.access_token == "abc"
|
||||||
assert srv.oauth_tokens is not None # persisted as JSON
|
assert srv.oauth_tokens is not None # persisted as JSON
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_storage(oauth_tokens):
|
||||||
|
class FakeSrv:
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = FakeSrv()
|
||||||
|
srv.oauth_tokens = oauth_tokens
|
||||||
|
|
||||||
|
class FakeQuery:
|
||||||
|
def filter(self, *a):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return srv
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def query(self, *a):
|
||||||
|
return FakeQuery()
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return srv, mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_falls_back_to_empty_dict_for_non_dict_json():
|
||||||
|
# A corrupted/migrated oauth_tokens column holding a JSON array, not an
|
||||||
|
# object, must not crash _load()'s callers with AttributeError.
|
||||||
|
_srv, storage = _fake_storage('["stale", "data"]')
|
||||||
|
assert storage._load() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tokens_returns_none_for_non_dict_oauth_tokens():
|
||||||
|
_srv, storage = _fake_storage("42")
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
return await storage.get_tokens()
|
||||||
|
|
||||||
|
assert asyncio.run(go()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_recovers_from_non_dict_oauth_tokens():
|
||||||
|
# _update() must not raise TypeError trying to item-assign into a list.
|
||||||
|
srv, storage = _fake_storage('["stale", "data"]')
|
||||||
|
storage._update("tokens", {"access_token": "new"})
|
||||||
|
assert json.loads(srv.oauth_tokens) == {"tokens": {"access_token": "new"}}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""cmd_add (scripts/odysseus-memory) must tolerate a non-dict row in the
|
||||||
|
existing store. Every other command funnels load_all() through
|
||||||
|
`_memory_entries()` (which drops non-dicts), but cmd_add iterated the raw
|
||||||
|
list in its dedup check: `any(e.get("id") == ... for e in all_entries)`
|
||||||
|
crashed with AttributeError on a corrupt/hand-edited memory.json row that
|
||||||
|
is not a dict. The isinstance check short-circuits before `.get`.
|
||||||
|
"""
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cli(monkeypatch):
|
||||||
|
svc = types.ModuleType("services.memory.memory")
|
||||||
|
svc.MemoryManager = MagicMock()
|
||||||
|
monkeypatch.setitem(sys.modules, "services.memory.memory", svc)
|
||||||
|
path = ROOT / "scripts" / "odysseus-memory"
|
||||||
|
loader = importlib.machinery.SourceFileLoader("odysseus_memory_cli_add", str(path))
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_add_tolerates_non_dict_existing_row(monkeypatch):
|
||||||
|
cli = _load_cli(monkeypatch)
|
||||||
|
cli._mgr = MagicMock()
|
||||||
|
cli._mgr.add_entry.return_value = {"id": "m2", "text": "new"}
|
||||||
|
cli._mgr.load_all.return_value = [
|
||||||
|
{"id": "m1", "text": "existing"},
|
||||||
|
"corrupt-row",
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
emitted = []
|
||||||
|
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
|
||||||
|
|
||||||
|
cli.cmd_add(SimpleNamespace(text="new", category="fact", owner=None))
|
||||||
|
|
||||||
|
assert emitted == [{"id": "m2", "text": "new"}]
|
||||||
|
cli._mgr.save.assert_called_once()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Regression: two concurrent callers of `_scheduled_poll_once` (the
|
||||||
|
in-process 30s poller and the `odysseus-mail poll-scheduled` CLI, which the
|
||||||
|
project's own docstrings warn can race on the same SQLite when
|
||||||
|
ODYSSEUS_INPROCESS_POLLERS is left enabled alongside an external cron/systemd
|
||||||
|
driver) must not both send the same scheduled email.
|
||||||
|
|
||||||
|
The old code selected pending rows, then only updated their status to 'sent'
|
||||||
|
*after* the SMTP send completed - two overlapping calls can both SELECT the
|
||||||
|
same 'pending' row before either UPDATEs it, so both send it. The fix adds
|
||||||
|
an atomic claim step (`UPDATE ... SET status='sending' WHERE status='pending'`)
|
||||||
|
before any work happens; only the caller whose UPDATE actually changes a row
|
||||||
|
proceeds, the other sees rowcount == 0 and skips it.
|
||||||
|
|
||||||
|
This test drives two real threads through the real `_scheduled_poll_once`
|
||||||
|
against a shared SQLite file, synchronized with a barrier so both reach the
|
||||||
|
SELECT at (as close to) the same moment as possible, and asserts the send
|
||||||
|
callback fired exactly once.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_pollers_do_not_double_send(tmp_path, monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import routes.email_pollers as email_pollers
|
||||||
|
|
||||||
|
db_path = tmp_path / "scheduled_emails.db"
|
||||||
|
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||||
|
monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
|
||||||
|
email_helpers._init_scheduled_db()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO scheduled_emails
|
||||||
|
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"sched-race-1",
|
||||||
|
"recipient@example.com",
|
||||||
|
"Subject",
|
||||||
|
"Body",
|
||||||
|
"[]",
|
||||||
|
"2000-01-01T00:00:00",
|
||||||
|
"1999-12-31T00:00:00",
|
||||||
|
"acct-alice",
|
||||||
|
"alice",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
send_calls = []
|
||||||
|
send_lock = threading.Lock()
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def fake_get_email_config(account_id=None, owner=""):
|
||||||
|
return {
|
||||||
|
"from_address": "alice@example.com",
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_user": "alice@example.com",
|
||||||
|
"smtp_password": "secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_send_smtp_message(*args, **kwargs):
|
||||||
|
# Widen the window between the claim and the actual send so a
|
||||||
|
# buggy (unclaimed) second poller has every opportunity to also
|
||||||
|
# get past its SELECT and attempt to send.
|
||||||
|
time.sleep(0.05)
|
||||||
|
with send_lock:
|
||||||
|
send_calls.append(threading.get_ident())
|
||||||
|
|
||||||
|
class FakeImap:
|
||||||
|
def __init__(self, account_id=None, owner=""):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def append(self, folder, flags, date_time, message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_pollers, "_get_email_config", fake_get_email_config)
|
||||||
|
monkeypatch.setattr(email_pollers, "_send_smtp_message", fake_send_smtp_message)
|
||||||
|
monkeypatch.setattr(email_pollers, "_imap", FakeImap)
|
||||||
|
monkeypatch.setattr(email_pollers, "_detect_sent_folder", lambda imap: "Sent")
|
||||||
|
monkeypatch.setattr(email_pollers, "_cleanup_compose_uploads", lambda attachments: None)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
barrier.wait()
|
||||||
|
results.append(email_pollers._scheduled_poll_once())
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=_run) for _ in range(2)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
assert len(send_calls) == 1, (
|
||||||
|
f"expected exactly one send for the racing pollers, got {len(send_calls)}: "
|
||||||
|
"the second poller must lose the atomic claim and skip the row"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
status = conn.execute(
|
||||||
|
"SELECT status FROM scheduled_emails WHERE id=?", ("sched-race-1",)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert status == "sent"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from services.tts.tts_service import TTSService
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_tolerates_non_string_provider(tmp_path):
|
||||||
|
"""A hand-edited/corrupt data/settings.json can store a non-string
|
||||||
|
tts_provider (e.g. null or a number). available reads it and calls
|
||||||
|
provider.startswith("endpoint:"), which raised AttributeError on a
|
||||||
|
non-str. It must instead fall through and report unavailable."""
|
||||||
|
service = TTSService(cache_dir=str(tmp_path))
|
||||||
|
service._load_settings = lambda: {
|
||||||
|
"tts_enabled": True,
|
||||||
|
"tts_provider": 123,
|
||||||
|
"tts_model": "tts-1",
|
||||||
|
"tts_voice": "alloy",
|
||||||
|
"tts_speed": "1",
|
||||||
|
}
|
||||||
|
assert service.available is False
|
||||||
Reference in New Issue
Block a user