Skip to content

Commit 04fd697

Browse files
committed
Merge upstream/dev: resolve conflicts in agent_loop.py and upload_handler.py
2 parents 3b6bacb + df2fad2 commit 04fd697

56 files changed

Lines changed: 8899 additions & 548 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,12 @@ async def _stop_background():
656656
# Sessions
657657
from routes.session_routes import setup_session_routes
658658
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
659-
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
659+
app.include_router(setup_session_routes(
660+
session_manager,
661+
session_config,
662+
webhook_manager=webhook_manager,
663+
upload_handler=upload_handler,
664+
))
660665

661666
# Admin Danger Zone wipes (Settings → System → Danger Zone)
662667
from routes.admin_wipe_routes import setup_admin_wipe_routes
@@ -685,7 +690,7 @@ async def _stop_background():
685690

686691
# History
687692
from routes.history.history_routes import setup_history_routes
688-
app.include_router(setup_history_routes(session_manager))
693+
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
689694

690695
# Search
691696
from routes.search_routes import setup_search_routes
@@ -764,7 +769,7 @@ async def _stop_background():
764769

765770
# Calendar (CalDAV)
766771
from routes.calendar_routes import setup_calendar_routes
767-
calendar_router = setup_calendar_routes()
772+
calendar_router = setup_calendar_routes(upload_handler=upload_handler)
768773
app.include_router(calendar_router)
769774

770775
# Shell (user-facing command execution)
@@ -839,7 +844,7 @@ async def _stop_background():
839844

840845
# Notes (Google Keep-style notes/todos)
841846
from routes.note_routes import setup_note_routes
842-
app.include_router(setup_note_routes(task_scheduler))
847+
app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))
843848

844849
# Email
845850
from routes.email_routes import setup_email_routes

core/database.py

Lines changed: 167 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import sqlite3
44
from datetime import datetime, timezone
55
from pathlib import Path
6+
from typing import Optional
7+
from urllib.parse import unquote, urlparse
68
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
7-
from sqlalchemy.engine import Engine
9+
from sqlalchemy.engine import Engine, make_url
810
from sqlalchemy.types import TypeDecorator
911
from sqlalchemy.ext.declarative import declarative_base, declared_attr
1012
from sqlalchemy.orm import relationship, sessionmaker, backref
1113

1214
from src.runtime_paths import get_app_root
15+
from core.platform_compat import safe_chmod, IS_WINDOWS
1316

1417
logger = logging.getLogger(__name__)
1518

@@ -42,12 +45,28 @@ def _default_database_url() -> str:
4245

4346

4447
def _normalize_sqlite_url(url: str) -> str:
45-
if not url.startswith("sqlite:///"):
48+
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
49+
try:
50+
parsed = make_url(url)
51+
except Exception:
52+
return url
53+
54+
if parsed.get_backend_name() != "sqlite":
4655
return url
47-
db_path = url.replace("sqlite:///", "", 1)
48-
if db_path == ":memory:" or os.path.isabs(db_path):
56+
57+
db_path = parsed.database
58+
if (
59+
not db_path
60+
or db_path == ":memory:"
61+
or str(db_path).lower().startswith("file:")
62+
or os.path.isabs(str(db_path))
63+
):
4964
return url
50-
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
65+
66+
absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
67+
return parsed.set(database=absolute_path).render_as_string(
68+
hide_password=False
69+
)
5170

5271

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

81+
82+
# Sidecar files SQLite can create next to the main DB. -journal is the default
83+
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
84+
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
85+
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")
86+
87+
88+
def _sqlite_db_path(url) -> Optional[str]:
89+
"""Return the filesystem path for a file-backed SQLite URL.
90+
91+
SQLite query parameters such as ``mode=memory`` only affect filename
92+
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
93+
file URLs must therefore remain file-backed even when they contain a query
94+
parameter named ``mode``.
95+
96+
For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
97+
local path. Other authorities are retained as UNC-style paths.
98+
"""
99+
if url.get_backend_name() != "sqlite":
100+
return None
101+
102+
db_path = url.database
103+
if not db_path or db_path == ":memory:":
104+
return None
105+
106+
db_path = str(db_path)
107+
query = {
108+
str(key).lower(): str(value).strip().lower()
109+
for key, value in dict(getattr(url, "query", {}) or {}).items()
110+
}
111+
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
112+
is_file_uri = db_path.lower().startswith("file:")
113+
114+
if not uri_enabled or not is_file_uri:
115+
return db_path
116+
117+
if (
118+
db_path.lower().startswith("file::memory:")
119+
or query.get("mode") == "memory"
120+
):
121+
return None
122+
123+
parsed = urlparse(db_path)
124+
fs_path = parsed.path or ""
125+
if not fs_path or fs_path == ":memory:":
126+
return None
127+
128+
authority = parsed.netloc
129+
if authority and authority.lower() != "localhost":
130+
fs_path = f"//{authority}{fs_path}"
131+
132+
return unquote(fs_path)
133+
62134
# Create session factory
63135
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
64136

@@ -1822,6 +1894,41 @@ def init_db():
18221894
"""
18231895
_migrate_model_endpoints()
18241896
Base.metadata.create_all(bind=engine)
1897+
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
1898+
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
1899+
# on Windows (ACL-restricted profile dir) and the path helper returns None for
1900+
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
1901+
# the umask default, and nothing below resets the mode. The path comes from
1902+
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
1903+
# DATABASE_URL still resolves to the real file instead of slipping through.
1904+
db_path = _sqlite_db_path(engine.url)
1905+
if db_path is not None:
1906+
# Fail closed-loud on the main file: this is the only access control on
1907+
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
1908+
# operator should hear about it. safe_chmod also returns False as a
1909+
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
1910+
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
1911+
logger.warning(
1912+
"Could not restrict %s to 0o600; it holds secrets and may be "
1913+
"world-readable. Check filesystem permissions and ownership.",
1914+
db_path,
1915+
)
1916+
# Re-lock any sidecars present at startup. New ones inherit the main
1917+
# file's mode (now 0o600, since we set it first), and they're usually
1918+
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
1919+
# install could still expose secret pages. Absent sidecars are the
1920+
# normal case, not an error — only a failed chmod warrants a warning.
1921+
for suffix in _SQLITE_SIDECARS:
1922+
sidecar = db_path + suffix
1923+
if (
1924+
os.path.exists(sidecar)
1925+
and not safe_chmod(sidecar, 0o600)
1926+
and not IS_WINDOWS
1927+
):
1928+
logger.warning(
1929+
"Could not restrict %s to 0o600; it may expose DB pages.",
1930+
sidecar,
1931+
)
18251932
_migrate_add_hidden_models_column()
18261933
_migrate_add_cached_models_column()
18271934
_migrate_add_pinned_models_column()
@@ -1907,6 +2014,20 @@ def _migrate_chat_messages_fts():
19072014
conn = None
19082015
try:
19092016
conn = sqlite3.connect(db_path)
2017+
fts_content_expr_new = (
2018+
"CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
2019+
"OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
2020+
"OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
2021+
"THEN '[inline media omitted from search index]' "
2022+
"ELSE COALESCE(new.content, '') END"
2023+
)
2024+
fts_content_expr_cm = (
2025+
"CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
2026+
"OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
2027+
"OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
2028+
"THEN '[inline media omitted from search index]' "
2029+
"ELSE COALESCE(cm.content, '') END"
2030+
)
19102031
try:
19112032
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
19122033
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
@@ -1915,18 +2036,22 @@ def _migrate_chat_messages_fts():
19152036
return
19162037

19172038
conn.executescript(
1918-
"""
2039+
f"""
19192040
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
19202041
content,
19212042
message_id UNINDEXED,
19222043
session_id UNINDEXED,
19232044
role UNINDEXED
19242045
);
19252046
2047+
DROP TRIGGER IF EXISTS chat_messages_fts_ai;
2048+
DROP TRIGGER IF EXISTS chat_messages_fts_ad;
2049+
DROP TRIGGER IF EXISTS chat_messages_fts_au;
2050+
19262051
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
19272052
AFTER INSERT ON chat_messages BEGIN
19282053
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1929-
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
2054+
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
19302055
END;
19312056
19322057
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
@@ -1938,21 +2063,22 @@ def _migrate_chat_messages_fts():
19382063
AFTER UPDATE ON chat_messages BEGIN
19392064
DELETE FROM chat_messages_fts WHERE message_id = old.id;
19402065
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1941-
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
2066+
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
19422067
END;
19432068
"""
19442069
)
19452070
conn.execute(
1946-
"""
2071+
f"""
19472072
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1948-
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role
2073+
SELECT {fts_content_expr_cm}, cm.id, cm.session_id, cm.role
19492074
FROM chat_messages cm
19502075
WHERE NOT EXISTS (
19512076
SELECT 1 FROM chat_messages_fts fts
19522077
WHERE fts.message_id = cm.id
19532078
)
19542079
"""
19552080
)
2081+
_scrub_legacy_chat_message_fts_media(conn)
19562082
conn.commit()
19572083
except Exception as e:
19582084
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
@@ -1963,6 +2089,37 @@ def _migrate_chat_messages_fts():
19632089
pass
19642090

19652091

2092+
def _scrub_legacy_chat_message_fts_media(conn) -> None:
2093+
"""Replace already-indexed inline media rows with searchable text only."""
2094+
try:
2095+
from src.attachment_refs import search_index_text
2096+
except Exception as e:
2097+
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub skipped: {e}")
2098+
return
2099+
2100+
try:
2101+
rows = conn.execute(
2102+
"""
2103+
SELECT id, session_id, role, content
2104+
FROM chat_messages
2105+
WHERE instr(COALESCE(content, ''), ';base64,') > 0
2106+
OR instr(COALESCE(content, ''), 'data:image/') > 0
2107+
OR instr(COALESCE(content, ''), 'data:audio/') > 0
2108+
"""
2109+
).fetchall()
2110+
for message_id, session_id, role, content in rows:
2111+
conn.execute("DELETE FROM chat_messages_fts WHERE message_id = ?", (message_id,))
2112+
conn.execute(
2113+
"""
2114+
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
2115+
VALUES (?, ?, ?, ?)
2116+
""",
2117+
(search_index_text(content), message_id, session_id, role),
2118+
)
2119+
except Exception as e:
2120+
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub failed: {e}")
2121+
2122+
19662123
def _migrate_add_email_smtp_security():
19672124
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
19682125
import sqlite3

0 commit comments

Comments
 (0)