feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -68,6 +68,45 @@ def test_conversation_project_binding():
|
||||
assert s.conversation_project("c2") == "" # unscoped
|
||||
|
||||
|
||||
def test_conversations_move_between_projects():
|
||||
# The Projects page lists chats by project_id and moves them with a PATCH;
|
||||
# if all_conversations() drops the column the list is silently empty.
|
||||
s = _store()
|
||||
s.create_conversation("c1", "projX")
|
||||
s.create_conversation("c2")
|
||||
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "projX", "c2": ""}
|
||||
|
||||
s.set_conversation_project("c2", "projX")
|
||||
s.set_conversation_project("c1", "") # removed from the project
|
||||
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "", "c2": "projX"}
|
||||
|
||||
|
||||
def test_project_instructions_and_scoped_memory():
|
||||
# The chat system prompt takes the project's instructions plus global facts
|
||||
# and this project's facts only — another project's must never leak in.
|
||||
from synapse.memory.store import MemoryItem
|
||||
|
||||
s = _store()
|
||||
p = s.create_project("Roof rebuild")
|
||||
assert s.project_instructions(p["id"]) == "" # default: no instructions
|
||||
assert s.project_instructions("ghost") == "" # unknown project
|
||||
assert s.set_project_instructions(p["id"], "answer as a roofer")
|
||||
assert not s.set_project_instructions("ghost", "x")
|
||||
assert s.project_instructions(p["id"]) == "answer as a roofer"
|
||||
|
||||
s.add(MemoryItem(id="g", text="lives in Ohio")) # global
|
||||
s.add(MemoryItem(id="a", text="uses metal panels", project_id=p["id"])) # this project
|
||||
s.add(MemoryItem(id="b", text="prefers Lua", project_id="other")) # elsewhere
|
||||
in_scope = [m.id for m in s.all() if m.project_id in ("", p["id"])]
|
||||
assert in_scope == ["g", "a"]
|
||||
|
||||
# Deleting a project keeps its chats and facts, unscoped.
|
||||
s.create_conversation("c1", p["id"])
|
||||
s.delete_project(p["id"])
|
||||
assert s.conversation_project("c1") == ""
|
||||
assert s.get("a").project_id == ""
|
||||
|
||||
|
||||
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||
s = _store()
|
||||
if not s.vec_enabled:
|
||||
@@ -178,3 +217,64 @@ def test_extract_text_by_type():
|
||||
out = _extract_text("blank.pdf", buf.getvalue())
|
||||
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
|
||||
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF
|
||||
|
||||
|
||||
def test_delete_conversation_takes_its_embeddings_with_it(tmp_path, monkeypatch):
|
||||
"""Stale vectors are inert — the search joins messages — but they still
|
||||
occupy slots in the ANN over-fetch, so recall of the surviving
|
||||
conversations quietly thins out as deleted ones pile up."""
|
||||
import sqlite3
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
s = PersistentMemoryStore(db)
|
||||
s.create_conversation("keep", "")
|
||||
s.create_conversation("drop", "")
|
||||
kept = s.add_message("keep", "user", "hello")
|
||||
doomed = s.add_message("drop", "user", "goodbye")
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
for mid in (kept, doomed):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||
(mid, "[0.0, 1.0]"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
s.delete_conversation("drop")
|
||||
|
||||
left = {r[0] for r in conn.execute("SELECT message_id FROM message_vectors")}
|
||||
assert left == {kept}, left
|
||||
|
||||
|
||||
def test_extraction_watermark_is_idempotent(tmp_path):
|
||||
"""The curator reads a conversation when it goes idle, so the watermark is
|
||||
what stops a restart (or a second sweep) from re-reading messages and
|
||||
re-saving the facts it already saved."""
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||
s.create_conversation("c", "")
|
||||
s.add_message("c", "user", "i bought a bike")
|
||||
last = s.add_message("c", "assistant", "nice")
|
||||
|
||||
pending, mark = s.pending_extraction("c")
|
||||
assert [m["role"] for m in pending] == ["user", "assistant"]
|
||||
assert mark == last
|
||||
|
||||
s.set_extracted_through("c", mark)
|
||||
assert s.pending_extraction("c") == ([], 0) # nothing new -> no model call
|
||||
|
||||
s.add_message("c", "user", "a 2019 trek")
|
||||
pending, _ = s.pending_extraction("c")
|
||||
assert [m["content"] for m in pending] == ["a 2019 trek"] # only the unread tail
|
||||
|
||||
|
||||
def test_idle_sweep_only_claims_quiet_conversations(tmp_path):
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||
s.create_conversation("fresh", "")
|
||||
s.add_message("fresh", "user", "still typing")
|
||||
assert s.conversations_awaiting_extraction(3600) == [] # too recent to be "over"
|
||||
assert s.conversations_awaiting_extraction(0) == ["fresh"]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Guards for the KDE theme's silent-failure modes.
|
||||
|
||||
Every check here corresponds to something that broke without producing an error
|
||||
message. They are static reads of the scripts and packages because the failures
|
||||
are configuration-shaped -- there is nothing to import and nothing that raises.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
KDE = REPO / "assets" / "themes" / "KDE"
|
||||
INSTALLER = KDE / "install-plasma.sh"
|
||||
LNF = KDE / "look-and-feel" / "com.nexusos.desktop"
|
||||
ICONS = REPO / "assets" / "themes" / "NexusOS-icons"
|
||||
THEME_INSTALLER = REPO / "assets" / "themes" / "install-theme.sh"
|
||||
|
||||
|
||||
def test_look_and_feel_is_copied_never_symlinked():
|
||||
"""KPackage skips symlinked package directories without a word, so a
|
||||
symlinked Global Theme simply never appears in System Settings."""
|
||||
text = INSTALLER.read_text()
|
||||
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
|
||||
for line in text.splitlines():
|
||||
if line.strip().startswith("ln -s"):
|
||||
assert "look-and-feel" not in line and "wallpapers" not in line, \
|
||||
f"KPackage package dir must not be symlinked: {line.strip()}"
|
||||
|
||||
|
||||
def test_plasmashell_restart_is_detached_from_the_callers_stdout():
|
||||
"""The restarted shell outlives the script. Inheriting stdout keeps the
|
||||
caller's pipe open forever, which hangs `ncp restore` after a successful
|
||||
apply."""
|
||||
text = INSTALLER.read_text()
|
||||
restart = [l for l in text.splitlines()
|
||||
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
|
||||
assert restart, "no plasmashell restart found"
|
||||
for line in restart:
|
||||
assert "setsid" in line, f"restart not detached: {line.strip()}"
|
||||
assert "</dev/null" in line and ">/dev/null" in line, \
|
||||
f"restart still holds the caller's stdio: {line.strip()}"
|
||||
|
||||
|
||||
def test_splash_renders_without_the_stage_signal():
|
||||
"""A splash gated on `stage == 2` shows a blank coloured screen if that
|
||||
signal never arrives -- what `ksplashqml --test` does."""
|
||||
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text()
|
||||
content = qml[qml.index("id: content"):]
|
||||
body = content[:content.index("OpacityAnimator")]
|
||||
assert "opacity: 0" not in body, "splash content starts invisible"
|
||||
assert "introAnimation" not in qml, "visibility still gated on a stage change"
|
||||
|
||||
|
||||
def test_sddm_theme_is_configured_in_exactly_one_place():
|
||||
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
|
||||
different config files meant the setting could disagree with itself."""
|
||||
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
|
||||
text = script.read_text()
|
||||
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
|
||||
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
|
||||
|
||||
|
||||
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
|
||||
"""The desktop stage used to bail out entirely without xfconf-query, so a
|
||||
Plasma box got no theme back from `ncp restore` at all."""
|
||||
text = (REPO / "bin" / "restore-linux.sh").read_text()
|
||||
assert "install-plasma.sh" in text, "restore never invokes the Plasma installer"
|
||||
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
|
||||
# The XFCE check must not be able to skip the Plasma branch or the branding.
|
||||
assert "exit 0\nfi\n" not in text.split("desktop stage")[1][:900], \
|
||||
"XFCE guard still exits the whole stage"
|
||||
|
||||
|
||||
def test_global_theme_package_is_well_formed():
|
||||
meta = json.loads((LNF / "metadata.json").read_text())
|
||||
assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
|
||||
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
|
||||
|
||||
defaults = (LNF / "contents" / "defaults").read_text()
|
||||
# Every component the Global Theme selects has to exist in the repo.
|
||||
assert "ColorScheme=NexusOS" in defaults
|
||||
assert (KDE / "plasma" / "NexusOS").is_dir()
|
||||
assert (KDE / "aurorae" / "NexusOS").is_dir()
|
||||
assert (KDE / "wallpaper" / "NexusOS" / "metadata.json").is_file()
|
||||
assert "Theme=com.nexusos.desktop" in defaults, "splash not wired to this package"
|
||||
|
||||
|
||||
def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
|
||||
"""QtSvg is SVG Tiny 1.2 and has no <pattern>, so the brushed-metal and
|
||||
machine-line textures vanish and the gradient renders flat. Every QML that
|
||||
shows that artwork must load the rasterized PNG."""
|
||||
qml_files = [
|
||||
KDE / "sddm" / "NexusOS-QML" / "Main.qml",
|
||||
LNF / "contents" / "splash" / "Splash.qml",
|
||||
]
|
||||
for f in qml_files:
|
||||
# Only the source: lines -- the comments deliberately mention the SVG,
|
||||
# since that is the file you edit and re-rasterize.
|
||||
sources = [l for l in f.read_text().splitlines()
|
||||
if "source:" in l and not l.strip().startswith("//")]
|
||||
bg = [l for l in sources if "background" in l]
|
||||
assert bg, f"{f.name} loads no background"
|
||||
for line in bg:
|
||||
assert "background.png" in line, \
|
||||
f"{f.name} loads a patterned SVG through QtSvg: {line.strip()}"
|
||||
|
||||
|
||||
def _defaults_sections():
|
||||
"""Parse the look-and-feel defaults into {section: {key: value}}."""
|
||||
out, section = {}, None
|
||||
for line in (LNF / "contents" / "defaults").read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
out[section] = {}
|
||||
elif line and "=" in line and section:
|
||||
k, v = line.split("=", 1)
|
||||
out[section][k] = v
|
||||
return out
|
||||
|
||||
|
||||
def test_lock_screen_theme_names_a_look_and_feel_package():
|
||||
"""Plasma 5.27 draws the lock screen from a look-and-feel package, and
|
||||
[Greeter]Theme names that package. A bare theme name (the old "NexusOS")
|
||||
is not one, so Plasma falls back to Breeze without saying anything."""
|
||||
greeter = _defaults_sections()["[kscreenlockerrc][Greeter]"]
|
||||
assert greeter["Theme"].endswith(".desktop"), \
|
||||
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
|
||||
|
||||
installer = INSTALLER.read_text()
|
||||
lock_lines = [l for l in installer.splitlines()
|
||||
if "kscreenlockerrc" in l and "--key Theme" in l]
|
||||
assert lock_lines, "installer never sets the lock screen theme"
|
||||
for line in lock_lines:
|
||||
assert ".desktop" in line, f"installer sets a non-package lock theme: {line.strip()}"
|
||||
|
||||
# And the lock wallpaper is the raster metal background.
|
||||
assert "background.png" in installer, "lock wallpaper not set to the metal raster"
|
||||
|
||||
|
||||
def _index_theme():
|
||||
"""Parse index.theme into (header dict, list of declared directories)."""
|
||||
header, section, dirs = {}, None, []
|
||||
for line in (ICONS / "index.theme").read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[") and line != "[Icon Theme]":
|
||||
section = line.strip("[]")
|
||||
dirs.append(section)
|
||||
elif "=" in line and not line.startswith("#") and section is None:
|
||||
k, v = line.split("=", 1)
|
||||
header[k.strip()] = v.strip()
|
||||
return header, dirs
|
||||
|
||||
|
||||
def test_every_declared_icon_directory_exists():
|
||||
"""index.theme declared 124 directories against 21 real ones, most copied
|
||||
from Papirus. A declared-but-missing directory is dead weight the icon
|
||||
loader walks on every lookup."""
|
||||
_, dirs = _index_theme()
|
||||
missing = [d for d in dirs if not (ICONS / d).is_dir()]
|
||||
assert not missing, f"index.theme declares directories that do not exist: {missing}"
|
||||
|
||||
listed = set(_index_theme()[0].get("Directories", "").split(","))
|
||||
assert listed == set(dirs), "Directories= and the [section] list disagree"
|
||||
|
||||
|
||||
def test_icon_theme_inherits_a_recolourable_parent():
|
||||
"""Papirus hardcodes its blues, so nothing the colour scheme does can reach
|
||||
them and the un-themed surface stayed blue forever. Breeze's icons carry
|
||||
ColorScheme-* classes that Plasma recolours from the active scheme."""
|
||||
header, _ = _index_theme()
|
||||
parents = [p.strip() for p in header["Inherits"].split(",")]
|
||||
assert not parents[0].lower().startswith("papirus"), \
|
||||
"primary parent cannot recolour from the colour scheme"
|
||||
assert parents[0] == "breeze-dark", f"expected breeze-dark first, got {parents[0]!r}"
|
||||
assert parents[-1] == "hicolor", "hicolor must remain the last-resort fallback"
|
||||
|
||||
|
||||
def test_icon_theme_is_installed_where_qt_looks():
|
||||
"""~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only,
|
||||
so installing there alone meant Plasma never found the theme and every icon
|
||||
fell back to Breeze without a word."""
|
||||
text = THEME_INSTALLER.read_text()
|
||||
links = [l for l in text.splitlines()
|
||||
if l.strip().startswith("link ") and "NexusOS-icons" in l]
|
||||
assert any(".local/share/icons" in l for l in links), \
|
||||
"icon theme is not installed to an XDG data dir; Plasma will not see it"
|
||||
assert any(".icons/NexusOS" in l for l in links), \
|
||||
"dropping ~/.icons would break the XFCE session and GTK apps"
|
||||
|
||||
|
||||
def test_inherits_check_reads_only_the_primary_parent():
|
||||
"""The installer compared the whole comma-separated Inherits value against a
|
||||
directory name, so a valid multi-parent list warned that an installed
|
||||
fallback was missing."""
|
||||
text = THEME_INSTALLER.read_text()
|
||||
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
|
||||
assert inh, "inheritance check not found"
|
||||
assert any("-f1" in l for l in inh), \
|
||||
"inheritance check still treats the whole Inherits list as one theme name"
|
||||
|
||||
|
||||
def test_panel_layout_is_portable():
|
||||
"""The panel script sets the launcher icon by absolute path. Hardcoding
|
||||
this box's home would give any other clone or user a missing icon, so the
|
||||
path is a placeholder the installer substitutes."""
|
||||
js = (KDE / "panel-layout.js").read_text()
|
||||
assert "/home/" not in js, "panel-layout.js hardcodes a home directory"
|
||||
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
|
||||
|
||||
installer = INSTALLER.read_text()
|
||||
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
|
||||
# Rewriting the panel wholesale on every restore would wipe later additions.
|
||||
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Mail account config — offline (no live IMAP/SMTP)."""
|
||||
from modules.mail import backend as mail
|
||||
|
||||
|
||||
def test_account_roundtrip_and_password_masking(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||
|
||||
assert mail.load_accounts() == []
|
||||
|
||||
pub = mail.save_account(None, {
|
||||
"username": "me@icloud.com",
|
||||
"password": "app-specific-pw",
|
||||
"imap_host": "imap.mail.me.com",
|
||||
"from_addr": "nexus@enderofwings.com",
|
||||
})
|
||||
account_id = pub["id"]
|
||||
# public view exposes a flag, never the secret
|
||||
assert pub["has_password"] is True
|
||||
assert "password" not in pub
|
||||
assert pub["configured"] is True
|
||||
assert mail.is_configured(account_id) is True
|
||||
|
||||
# a blank password on update keeps the stored one (write-only field)
|
||||
mail.save_account(account_id, {"from_name": "Nexus"})
|
||||
assert mail.get_account(account_id)["password"] == "app-specific-pw"
|
||||
assert mail.get_account(account_id)["from_name"] == "Nexus"
|
||||
|
||||
# defaults target iCloud
|
||||
assert mail.get_account(account_id)["smtp_host"] == "smtp.mail.me.com"
|
||||
|
||||
|
||||
def test_multiple_accounts_are_independent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||
|
||||
a = mail.save_account(None, {"username": "a@icloud.com", "password": "pw-a", "label": "Personal"})
|
||||
b = mail.save_account(None, {"username": "b@icloud.com", "password": "pw-b", "label": "Work"})
|
||||
assert a["id"] != b["id"]
|
||||
|
||||
accounts = mail.public_accounts()
|
||||
assert {x["id"] for x in accounts} == {a["id"], b["id"]}
|
||||
|
||||
mail.delete_account(a["id"])
|
||||
remaining = mail.public_accounts()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0]["id"] == b["id"]
|
||||
assert mail.is_configured(a["id"]) is False
|
||||
|
||||
|
||||
def test_creds_file_lives_outside_the_db(monkeypatch, tmp_path):
|
||||
# Mail secrets must never ride in memory.db (which bin/sync.py dumps to git).
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
|
||||
mail.save_account(None, {"username": "u", "password": "p"})
|
||||
assert (tmp_path / "acct.json").exists()
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
assert "password" not in PersistentMemoryStore._SETTINGS_DEFAULTS
|
||||
|
||||
|
||||
def test_legacy_single_account_file_migrates(tmp_path, monkeypatch):
|
||||
import json
|
||||
f = tmp_path / "acct.json"
|
||||
f.write_text(json.dumps({
|
||||
"imap_host": "imap.mail.me.com", "imap_port": 993,
|
||||
"smtp_host": "smtp.mail.me.com", "smtp_port": 587,
|
||||
"username": "legacy@icloud.com", "password": "old-pw",
|
||||
"from_addr": "legacy@enderofwings.com", "from_name": "",
|
||||
}))
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", f)
|
||||
|
||||
accounts = mail.load_accounts()
|
||||
assert len(accounts) == 1
|
||||
assert accounts[0]["username"] == "legacy@icloud.com"
|
||||
assert "id" in accounts[0]
|
||||
|
||||
# migration is persisted — the file is rewritten in list form
|
||||
assert json.loads(f.read_text())["accounts"][0]["username"] == "legacy@icloud.com"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Credential handling for the mail module.
|
||||
|
||||
Both checks guard fixes for real defects: the account file used to be written at
|
||||
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
|
||||
Python's stdlib SSL context, which verifies nothing.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import stat
|
||||
|
||||
from modules.mail import backend as mail
|
||||
|
||||
|
||||
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
||||
|
||||
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
|
||||
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
||||
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
|
||||
|
||||
# The password round-trips to disk but never to the API.
|
||||
assert mail.load_accounts()[0]["password"] == "secret"
|
||||
assert "password" not in mail.public_account(mail.get_account("abc"))
|
||||
assert mail.public_account(mail.get_account("abc"))["has_password"] is True
|
||||
assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"]
|
||||
|
||||
|
||||
def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch):
|
||||
"""The old code wrote at the umask and chmodded afterwards, so the file sat
|
||||
world-readable for the length of the write. Assert the handle it is written
|
||||
through is already 0600 -- the pre-fix version never wrote through a handle
|
||||
at all (json.dumps to a string, then write_text), so this fails against it."""
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||
seen = {}
|
||||
real_dump = mail.json.dump
|
||||
|
||||
def spy(obj, fh, **kw):
|
||||
seen["mode"] = stat.S_IMODE(os.fstat(fh.fileno()).st_mode)
|
||||
return real_dump(obj, fh, **kw)
|
||||
|
||||
monkeypatch.setattr(mail.json, "dump", spy)
|
||||
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
||||
|
||||
assert seen["mode"] == 0o600, f"written through a {oct(seen['mode'])} handle"
|
||||
|
||||
|
||||
def test_tls_context_verifies_certificate_and_hostname():
|
||||
# ssl._create_stdlib_context(), the imaplib/smtplib fallback, gives
|
||||
# CERT_NONE + check_hostname False -- which is what this replaced.
|
||||
assert mail._TLS.verify_mode is ssl.CERT_REQUIRED
|
||||
assert mail._TLS.check_hostname is True
|
||||
@@ -0,0 +1,22 @@
|
||||
"""modules/registry.py auto-discovery — no per-module registration required."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from modules.registry import ROUTERS
|
||||
|
||||
|
||||
def test_discovers_every_module_with_a_router():
|
||||
prefixes = sorted(r.prefix for r in ROUTERS)
|
||||
assert prefixes == ["/mail", "/network"]
|
||||
assert all(isinstance(r, APIRouter) for r in ROUTERS)
|
||||
|
||||
|
||||
def test_folder_without_router_py_is_skipped(tmp_path, monkeypatch):
|
||||
# A module folder that hasn't grown a router.py yet (e.g. mid-scaffold)
|
||||
# must not blow up discovery.
|
||||
import modules.registry as registry
|
||||
(tmp_path / "not_a_module").mkdir()
|
||||
(tmp_path / "not_a_module" / "__init__.py").touch()
|
||||
monkeypatch.setattr(registry, "_MODULES_DIR", tmp_path)
|
||||
# No router.py in the folder, so discovery must skip it without raising
|
||||
# (it never even attempts the import).
|
||||
assert registry._discover() == []
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Network module — offline (no real pings, no real nmcli/NetworkManager calls)."""
|
||||
import subprocess
|
||||
|
||||
from modules.network import backend as net
|
||||
|
||||
|
||||
def test_primary_connection_returns_expected_shape():
|
||||
conn = net.primary_connection()
|
||||
assert set(conn.keys()) == {"interface", "ip", "type"}
|
||||
assert conn["type"] in ("wifi", "ethernet", "offline")
|
||||
|
||||
|
||||
def test_vpn_status_unavailable_when_nmcli_missing(monkeypatch):
|
||||
monkeypatch.setattr(net, "_nmcli_available", lambda: False)
|
||||
assert net.vpn_status() == {"available": False}
|
||||
|
||||
|
||||
def test_vpn_status_configured_but_disconnected(monkeypatch):
|
||||
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
|
||||
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:disconnected")
|
||||
status = net.vpn_status()
|
||||
assert status == {"available": True, "configured": True, "name": "wgs_client", "connected": False}
|
||||
|
||||
|
||||
def test_vpn_status_connected(monkeypatch):
|
||||
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
|
||||
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:activated")
|
||||
status = net.vpn_status()
|
||||
assert status["connected"] is True
|
||||
|
||||
|
||||
def test_vpn_toggle_raises_without_a_configured_tunnel(monkeypatch):
|
||||
monkeypatch.setattr(net, "vpn_status", lambda: {"available": True, "configured": False, "name": None, "connected": False})
|
||||
try:
|
||||
net.vpn_toggle(True)
|
||||
assert False, "expected RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def test_target_crud_roundtrip(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
|
||||
|
||||
assert net.list_targets() == []
|
||||
t = net.add_target("Router", "192.168.50.1")
|
||||
assert t["label"] == "Router" and t["host"] == "192.168.50.1"
|
||||
|
||||
targets = net.list_targets()
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["id"] == t["id"]
|
||||
|
||||
net.delete_target(t["id"])
|
||||
assert net.list_targets() == []
|
||||
|
||||
|
||||
def test_ping_parses_latency_on_success(monkeypatch):
|
||||
monkeypatch.setattr(subprocess, "check_output", lambda *a, **k: "64 bytes from 1.1.1.1: icmp_seq=1 ttl=56 time=12.3 ms")
|
||||
result = net.ping("1.1.1.1")
|
||||
assert result["ok"] is True
|
||||
assert result["latency_ms"] == 12.3
|
||||
|
||||
|
||||
def test_ping_reports_failure(monkeypatch):
|
||||
def raise_failed(*a, **k):
|
||||
raise subprocess.CalledProcessError(1, "ping")
|
||||
monkeypatch.setattr(subprocess, "check_output", raise_failed)
|
||||
result = net.ping("10.255.255.1")
|
||||
assert result == {"ok": False, "latency_ms": None}
|
||||
|
||||
|
||||
def test_ping_targets_merges_target_and_result(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
|
||||
net.add_target("Router", "192.168.50.1")
|
||||
monkeypatch.setattr(net, "ping", lambda host: {"ok": True, "latency_ms": 5.0})
|
||||
|
||||
results = net.ping_targets()
|
||||
assert len(results) == 1
|
||||
assert results[0]["host"] == "192.168.50.1"
|
||||
assert results[0]["ok"] is True
|
||||
assert results[0]["latency_ms"] == 5.0
|
||||
+69
-18
@@ -19,7 +19,7 @@ from synapse.nexus_config import DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL
|
||||
from synapse.ollama_manager import OllamaManager
|
||||
from synapse import ollama_manager
|
||||
from synapse.icons.compositor import _is_allowed_path
|
||||
from synapse.playbook_manager import PlaybookManager
|
||||
from synapse import playbook_manager
|
||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -162,9 +162,9 @@ def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch):
|
||||
instructions="Answer briefly.", order=0))
|
||||
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||
|
||||
assert PlaybookManager.get_main_playbook().id == "main"
|
||||
assert [p.id for p in PlaybookManager.get_context_playbooks()] == ["ctx"]
|
||||
assert PlaybookManager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
|
||||
assert playbook_manager.get_main_playbook().id == "main"
|
||||
assert [p.id for p in playbook_manager.get_context_playbooks()] == ["ctx"]
|
||||
assert playbook_manager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
|
||||
|
||||
|
||||
def test_settings_round_trip_over_defaults(tmp_path):
|
||||
@@ -180,10 +180,10 @@ def test_past_conversations_are_searchable(tmp_path):
|
||||
# silently drops the assistant's recall of past chats.
|
||||
store = PersistentMemoryStore(tmp_path / "memory.db")
|
||||
store.create_conversation("c1")
|
||||
store.add_message("c1", "user", "how do I mount the Wingdrive?")
|
||||
store.add_message("c1", "user", "how do I mount the backup drive?")
|
||||
store.add_message("c1", "assistant", "use rsync over ssh")
|
||||
|
||||
assert store.search_conversations("wingdrive") # case-insensitive substring
|
||||
assert store.search_conversations("BACKUP drive") # case-insensitive substring
|
||||
assert store.search_conversations("nothing here") == []
|
||||
assert store.search_conversations(" ") == []
|
||||
|
||||
@@ -271,10 +271,10 @@ def test_sync_compare_detects_direction(tmp_path):
|
||||
def write(rows):
|
||||
db.unlink(missing_ok=True)
|
||||
with sq.connect(db) as conn:
|
||||
# updated_at REAL, matching the production schema in store.py. A TEXT
|
||||
# column here hid a real TypeError for months: the comparison in
|
||||
# _extra() ran str-vs-str in the test and str-vs-float in the field.
|
||||
conn.executescript(
|
||||
# updated_at REAL, matching the production schema in store.py. A TEXT
|
||||
# column here hid a real TypeError for months: the comparison in
|
||||
# _extra() ran str-vs-str in the test and str-vs-float in the field.
|
||||
"create table conversations (id text primary key, updated_at real not null);"
|
||||
"create table memory (id text primary key);"
|
||||
)
|
||||
@@ -352,6 +352,15 @@ def test_genmon_configs_are_written_with_the_panel_down():
|
||||
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
|
||||
|
||||
|
||||
def test_plank_is_actually_launched():
|
||||
"""Restoring ~/.config/plank only brings back the dock's launchers - nothing
|
||||
in it starts Plank. The primary-follow watcher is what launches and revives
|
||||
it, so it needs an autostart entry or a fresh box has no dock at all."""
|
||||
desktop = REPO_ROOT / "management" / "autostart" / "plank.desktop"
|
||||
assert "plank-primary-watch.sh" in desktop.read_text()
|
||||
assert "plank.desktop" in (REPO_ROOT / "bin" / "panel" / "install.sh").read_text()
|
||||
|
||||
|
||||
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
|
||||
Devices:
|
||||
========
|
||||
@@ -455,30 +464,72 @@ def test_dump_round_trips_a_db_holding_vec_tables(tmp_path):
|
||||
def test_curator_drops_fabricated_facts():
|
||||
"""The curator model invents two classes of fact no prompt wording stopped
|
||||
(verified against mistral:7b), and both reached the real memory DB: absence
|
||||
claims read off the existing-memory block ("Jon does not have any pets",
|
||||
claims read off the existing-memory block ("the user does not have any pets",
|
||||
which contradicted four cats on file) and specifics lifted from the
|
||||
ASSISTANT's reply ("Jon's main development machine is a MacBook Pro", from
|
||||
ASSISTANT's reply ("the user's main development machine is a MacBook Pro", from
|
||||
the user message "What am I developing on?"). Deterministic guard, so it
|
||||
holds whatever the model does."""
|
||||
from synapse.memory.extractor import _reject_reason
|
||||
|
||||
# Absence claims are never facts.
|
||||
assert _reject_reason("Jon does not have any pets", "do i have any pets?")
|
||||
assert _reject_reason("Jon's favorite episode is unknown", "what's my favorite episode?")
|
||||
assert _reject_reason("Jon has not specified an interest", "tell me about stargate")
|
||||
assert _reject_reason("the user does not have any pets", "do i have any pets?")
|
||||
assert _reject_reason("the user's favorite episode is unknown", "what's my favorite episode?")
|
||||
assert _reject_reason("the user has not specified an interest", "tell me about stargate")
|
||||
|
||||
# Specifics the user never typed came from the assistant.
|
||||
assert _reject_reason("Jon's main dev machine is a MacBook Pro", "What am I developing on?")
|
||||
assert _reject_reason("the user's main dev machine is a MacBook Pro", "What am I developing on?")
|
||||
|
||||
# ...but the same shape grounded in the user's own words must survive.
|
||||
assert _reject_reason(
|
||||
"Jon owns a 2000 Ford Ranger with a 3.0L V6",
|
||||
"the user owns a 2000 Ford Ranger with a 3.0L V6",
|
||||
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
|
||||
assert _reject_reason(
|
||||
"Jon has a beagle named Biscuit",
|
||||
"the user has a beagle named Biscuit",
|
||||
"i just adopted a dog named Biscuit, he's a beagle") is None
|
||||
# A fact carrying no proper nouns or numbers can't be grounding-checked;
|
||||
# the prompt owns that case, so the guard must let it through.
|
||||
assert _reject_reason(
|
||||
"Jon prefers short answers over long explanations",
|
||||
"the user prefers short answers over long explanations",
|
||||
"i really prefer short answers over long explanations") is None
|
||||
|
||||
|
||||
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
|
||||
from synapse import main
|
||||
# Fake git so the test never touches the network. Behind → the remote
|
||||
# VERSION file, not this checkout's, is what the UI advertises.
|
||||
calls = {
|
||||
("rev-list", "--count", "HEAD..origin/main"): "3",
|
||||
("show", "origin/main:VERSION"): "9.9.9\n",
|
||||
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
|
||||
}
|
||||
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
|
||||
|
||||
# An unreachable remote must not 500 the sidebar.
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("could not resolve host")
|
||||
monkeypatch.setattr(main, "_git", boom)
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 0 and "could not resolve host" in body["error"]
|
||||
|
||||
|
||||
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
|
||||
import subprocess
|
||||
from synapse import main
|
||||
seen = {}
|
||||
|
||||
def fake_popen(argv, **kw):
|
||||
seen["argv"], seen["kw"] = argv, kw
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(main, "_update_running", False)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
client = TestClient(app)
|
||||
assert client.post("/update/apply").json()["started"] is True
|
||||
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
|
||||
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
|
||||
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
|
||||
|
||||
# Double-click must not launch a second pull/rebuild over the first.
|
||||
assert client.post("/update/apply").json()["started"] is False
|
||||
|
||||
@@ -149,3 +149,60 @@ def test_tool_loop_degrades_when_model_returns_no_dict():
|
||||
statuses = asyncio.run(_drain(_run_tool_loop(_NoToolManager(), messages, "m", [{}], None, None)))
|
||||
assert statuses == [] # no tool ran
|
||||
assert messages == before # untouched -> falls back to a plain stream
|
||||
|
||||
|
||||
def test_read_file_stays_inside_the_repo():
|
||||
"""The repo-file tools are the fix for the model inventing paths like
|
||||
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
|
||||
import json
|
||||
|
||||
def read(p):
|
||||
return asyncio.run(tools._read_file(p))
|
||||
|
||||
assert "escapes" in read("../../etc/passwd")
|
||||
# a leading slash is treated as repo-relative, so it lands nowhere real
|
||||
assert "root:" not in read("/etc/passwd")
|
||||
assert "required" in read("")
|
||||
# private data and heavy trees are refused even though they're in-repo
|
||||
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
|
||||
assert "not readable" in read(denied), denied
|
||||
assert "does not exist" in read("nexus/nlp.py")
|
||||
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
|
||||
|
||||
|
||||
def test_list_files_globs_the_repo_without_leaking_denied_paths():
|
||||
import json
|
||||
|
||||
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
|
||||
assert "synapse/main.py" in hits
|
||||
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
|
||||
|
||||
|
||||
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
||||
"""A reference playbook routed into the prompt must bring its tools with it.
|
||||
Without this the model reads instructions like "you can read the codebase"
|
||||
while being advertised zero tools — and narrates tool calls it never made."""
|
||||
from synapse.main import _route_playbooks
|
||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||
|
||||
# Own store, not data/playbooks: the live set is the operator's, and a
|
||||
# published clone ships different playbooks - this asserted on data that
|
||||
# travels with one machine.
|
||||
store = PlaybookFileStore(tmp_path)
|
||||
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
|
||||
instructions="i", order=0))
|
||||
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
|
||||
instructions="You can read the codebase.", order=1,
|
||||
tags=["synapse", "backend"],
|
||||
tools=["read_file", "list_files"]))
|
||||
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||
import synapse.playbook_manager as pm
|
||||
|
||||
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
|
||||
names = {pb.title for pb in routed}
|
||||
assert "NexusOS Developer" in names, names
|
||||
|
||||
granted = {t for pb in routed for t in (pb.tools or [])}
|
||||
assert {"read_file", "list_files"} <= granted, granted
|
||||
# none of them are action tools, so they survive the default policy (off)
|
||||
assert tools.schemas_for(sorted(granted), allow_actions=False)
|
||||
|
||||
Reference in New Issue
Block a user