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:
janvanwan
2026-08-25 09:13:55 -05:00
parent fe5d18afa7
commit 42eaed647a
88 changed files with 4280 additions and 1888 deletions
+100
View File
@@ -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"]
+214
View File
@@ -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"
+75
View File
@@ -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"
+53
View File
@@ -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
+22
View File
@@ -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() == []
+80
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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)