Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c67deaa
Merge pull request #5283 from pewdiepie-archdaemon/sync-main-into-dev…
pewdiepie-archdaemon Jul 7, 2026
2d81770
fix(chat): restore missing _explicit_web_intent definition (#5290)
SteveHolloway71 Jul 8, 2026
54f1d01
fix(chat): require explicit web search enable
RaresKeY Jul 8, 2026
3064819
fix(chat): give extensionless image/audio uploads a valid MIME subtyp…
Ohualtex Jul 8, 2026
a35384e
fix(copilot): guard request_flags against a non-dict last message (#5…
wbaxterh Jul 8, 2026
d88c8cb
Merge pull request #5313 from RaresKeY/fix/chat-web-search-explicit-deny
bitboody Jul 8, 2026
807e92c
docs: remove completed troubleshooting cookbook task from ROADMAP (#4…
wbaxterh Jul 10, 2026
4901a96
fix(reminders): sanitize ntfy Title header to ASCII (#5208)
Am-GJ Jul 10, 2026
a8d215a
fix(tasks): scope manage_tasks mutations to an exact task owner (#5264)
ashvinctrl Jul 11, 2026
7a1c739
fix: hwfit params_b/is_prequantized crash on non-string catalog field…
afonsopc Jul 11, 2026
7b25b6d
fix: odysseus-memory cmd_add crashes on non-dict existing memory row …
afonsopc Jul 11, 2026
667f9e4
fix: _matchesCombo crashes on non-string keybind from server (#2049)
afonsopc Jul 11, 2026
def3483
fix: TTS available crashes on non-string tts_provider (#2034)
afonsopc Jul 11, 2026
e0fd681
fix(email): never fall back to sequence-number IMAP ops for move/flag…
davieduard0x01 Jul 11, 2026
21c7bf8
fix(email): atomically claim scheduled emails before sending (#5110)
jagadish-zentiti Jul 11, 2026
bc771fb
fix(mcp): guard DbTokenStorage against non-dict oauth_tokens JSON (#5…
jagadish-zentiti Jul 11, 2026
6efc07c
Skip vanished backup list entries (#2006)
redpersongpt Jul 11, 2026
e6d9d68
CalDAV: close the DAVClient on sync and write-back paths (#4793)
tanmayraut45 Jul 11, 2026
1f8687a
fix(calendar): trust operator CA bundle in CalDAV test_connection (#4…
wbaxterh Jul 11, 2026
890d6a0
fix(agent): cancel orphaned tool task when SSE client disconnects mid…
jagadish-zentiti Jul 11, 2026
2531ba4
fix(chat): Expand user chat bubble edit textbox width (#3963)
dltechy Jul 11, 2026
801c3a2
fix(email): use UID commands instead of sequence numbers in IMAP fetc…
pkarlsson-codes Jul 11, 2026
732b207
fix(email): clear bulk selection on context change (#5229)
RaresKeY Jul 11, 2026
7f3fd77
fix: preserve pythonpath for built-in mcp servers (#5117)
RaresKeY Jul 11, 2026
b571c7d
fix(markdown): stop currency dollars rendering as KaTeX inline math (…
TuanKietTran Jul 11, 2026
6b8f845
fix(llm): avoid blocking Kimi Code async header probes (#5231)
RaresKeY Jul 11, 2026
d16b849
fix(stabilization): harden attachment lifecycle and agent guard signa…
falabellamichael Jul 11, 2026
f13e54d
fix(cleanup): update MODULE_SUMMARY and remove dead MEMORY_DOC paths …
eyeofastarte Jul 11, 2026
df2fad2
fix(db): restrict data/app.db to 0600 (#4420)
0xLeathery Jul 11, 2026
5971b09
fix(mcp_manager): implement concurrent server connections with timeou…
bitboody Jul 8, 2026
c1d6287
fix(mcp_manager): remove timeout from MCP connection attempts and han…
bitboody Jul 12, 2026
c80462e
fix(skills): block private SSRF targets and revalidate redirects in i…
EmirCobanOfficial Jul 14, 2026
9325865
fix(mcp): close the exit stack when an HTTP MCP connect fails
MahmutDehhan Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ the codebase, you are probably right to stay away.
and WSL all need coverage.

- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
Expand Down
15 changes: 10 additions & 5 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,12 @@ async def _stop_background():
# Sessions
from routes.session_routes import setup_session_routes
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
app.include_router(setup_session_routes(
session_manager,
session_config,
webhook_manager=webhook_manager,
upload_handler=upload_handler,
))

# Admin Danger Zone wipes (Settings → System → Danger Zone)
from routes.admin_wipe_routes import setup_admin_wipe_routes
Expand Down Expand Up @@ -684,7 +689,7 @@ async def _stop_background():

# History
from routes.history.history_routes import setup_history_routes
app.include_router(setup_history_routes(session_manager))
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))

# Search
from routes.search_routes import setup_search_routes
Expand Down Expand Up @@ -763,7 +768,7 @@ async def _stop_background():

# Calendar (CalDAV)
from routes.calendar_routes import setup_calendar_routes
calendar_router = setup_calendar_routes()
calendar_router = setup_calendar_routes(upload_handler=upload_handler)
app.include_router(calendar_router)

# Shell (user-facing command execution)
Expand Down Expand Up @@ -826,7 +831,7 @@ async def _stop_background():

# Notes (Google Keep-style notes/todos)
from routes.note_routes import setup_note_routes
app.include_router(setup_note_routes(task_scheduler))
app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))

# Email
from routes.email_routes import setup_email_routes
Expand Down Expand Up @@ -1043,7 +1048,7 @@ async def _startup_mcp_connections():
except BaseException as e:
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
try:
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20)
await mcp_manager.connect_all_enabled()
except asyncio.TimeoutError:
logger.warning("User MCP startup timed out (non-critical)")
except BaseException as e:
Expand Down
177 changes: 167 additions & 10 deletions core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
from sqlalchemy.engine import Engine
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import relationship, sessionmaker, backref

from src.runtime_paths import get_app_root
from core.platform_compat import safe_chmod, IS_WINDOWS

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -42,12 +45,28 @@ def _default_database_url() -> str:


def _normalize_sqlite_url(url: str) -> str:
if not url.startswith("sqlite:///"):
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
try:
parsed = make_url(url)
except Exception:
return url

if parsed.get_backend_name() != "sqlite":
return url
db_path = url.replace("sqlite:///", "", 1)
if db_path == ":memory:" or os.path.isabs(db_path):

db_path = parsed.database
if (
not db_path
or db_path == ":memory:"
or str(db_path).lower().startswith("file:")
or os.path.isabs(str(db_path))
):
return url
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"

absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
return parsed.set(database=absolute_path).render_as_string(
hide_password=False
)


# Get database URL from environment, default to SQLite in DATA_DIR
Expand All @@ -59,6 +78,59 @@ def _normalize_sqlite_url(url: str) -> str:
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
)


# Sidecar files SQLite can create next to the main DB. -journal is the default
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")


def _sqlite_db_path(url) -> Optional[str]:
"""Return the filesystem path for a file-backed SQLite URL.

SQLite query parameters such as ``mode=memory`` only affect filename
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
file URLs must therefore remain file-backed even when they contain a query
parameter named ``mode``.

For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
local path. Other authorities are retained as UNC-style paths.
"""
if url.get_backend_name() != "sqlite":
return None

db_path = url.database
if not db_path or db_path == ":memory:":
return None

db_path = str(db_path)
query = {
str(key).lower(): str(value).strip().lower()
for key, value in dict(getattr(url, "query", {}) or {}).items()
}
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
is_file_uri = db_path.lower().startswith("file:")

if not uri_enabled or not is_file_uri:
return db_path

if (
db_path.lower().startswith("file::memory:")
or query.get("mode") == "memory"
):
return None

parsed = urlparse(db_path)
fs_path = parsed.path or ""
if not fs_path or fs_path == ":memory:":
return None

authority = parsed.netloc
if authority and authority.lower() != "localhost":
fs_path = f"//{authority}{fs_path}"

return unquote(fs_path)

# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Expand Down Expand Up @@ -1819,6 +1891,41 @@ def init_db():
"""
_migrate_model_endpoints()
Base.metadata.create_all(bind=engine)
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
# on Windows (ACL-restricted profile dir) and the path helper returns None for
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
# the umask default, and nothing below resets the mode. The path comes from
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
# DATABASE_URL still resolves to the real file instead of slipping through.
db_path = _sqlite_db_path(engine.url)
if db_path is not None:
# Fail closed-loud on the main file: this is the only access control on
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
# operator should hear about it. safe_chmod also returns False as a
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
logger.warning(
"Could not restrict %s to 0o600; it holds secrets and may be "
"world-readable. Check filesystem permissions and ownership.",
db_path,
)
# Re-lock any sidecars present at startup. New ones inherit the main
# file's mode (now 0o600, since we set it first), and they're usually
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
# install could still expose secret pages. Absent sidecars are the
# normal case, not an error — only a failed chmod warrants a warning.
for suffix in _SQLITE_SIDECARS:
sidecar = db_path + suffix
if (
os.path.exists(sidecar)
and not safe_chmod(sidecar, 0o600)
and not IS_WINDOWS
):
logger.warning(
"Could not restrict %s to 0o600; it may expose DB pages.",
sidecar,
)
_migrate_add_hidden_models_column()
_migrate_add_cached_models_column()
_migrate_add_pinned_models_column()
Expand Down Expand Up @@ -1904,6 +2011,20 @@ def _migrate_chat_messages_fts():
conn = None
try:
conn = sqlite3.connect(db_path)
fts_content_expr_new = (
"CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
"OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
"OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
"THEN '[inline media omitted from search index]' "
"ELSE COALESCE(new.content, '') END"
)
fts_content_expr_cm = (
"CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
"OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
"OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
"THEN '[inline media omitted from search index]' "
"ELSE COALESCE(cm.content, '') END"
)
try:
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
Expand All @@ -1912,18 +2033,22 @@ def _migrate_chat_messages_fts():
return

conn.executescript(
"""
f"""
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
content,
message_id UNINDEXED,
session_id UNINDEXED,
role UNINDEXED
);

DROP TRIGGER IF EXISTS chat_messages_fts_ai;
DROP TRIGGER IF EXISTS chat_messages_fts_ad;
DROP TRIGGER IF EXISTS chat_messages_fts_au;

CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
AFTER INSERT ON chat_messages BEGIN
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
END;

CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
Expand All @@ -1935,21 +2060,22 @@ def _migrate_chat_messages_fts():
AFTER UPDATE ON chat_messages BEGIN
DELETE FROM chat_messages_fts WHERE message_id = old.id;
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
END;
"""
)
conn.execute(
"""
f"""
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role
SELECT {fts_content_expr_cm}, cm.id, cm.session_id, cm.role
FROM chat_messages cm
WHERE NOT EXISTS (
SELECT 1 FROM chat_messages_fts fts
WHERE fts.message_id = cm.id
)
"""
)
_scrub_legacy_chat_message_fts_media(conn)
conn.commit()
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
Expand All @@ -1960,6 +2086,37 @@ def _migrate_chat_messages_fts():
pass


def _scrub_legacy_chat_message_fts_media(conn) -> None:
"""Replace already-indexed inline media rows with searchable text only."""
try:
from src.attachment_refs import search_index_text
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub skipped: {e}")
return

try:
rows = conn.execute(
"""
SELECT id, session_id, role, content
FROM chat_messages
WHERE instr(COALESCE(content, ''), ';base64,') > 0
OR instr(COALESCE(content, ''), 'data:image/') > 0
OR instr(COALESCE(content, ''), 'data:audio/') > 0
"""
).fetchall()
for message_id, session_id, role, content in rows:
conn.execute("DELETE FROM chat_messages_fts WHERE message_id = ?", (message_id,))
conn.execute(
"""
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (?, ?, ?, ?)
""",
(search_index_text(content), message_id, session_id, role),
)
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub failed: {e}")


def _migrate_add_email_smtp_security():
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
import sqlite3
Expand Down
Loading
Loading