diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 2a4f3143..ed3ab538 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -1,11 +1,12 @@ """Shared helpers for reading Hermes Agent sessions from state.db.""" import json import logging +import os import sqlite3 import sys from contextlib import closing from pathlib import Path, PurePosixPath, PureWindowsPath -from urllib.parse import quote +from urllib.parse import quote, quote_from_bytes logger = logging.getLogger(__name__) @@ -56,7 +57,13 @@ def state_db_file_uri(db_path, platform: str | None = None) -> str: # ``file:///C:/...`` shape (``Path.as_uri()`` leaves it unescaped too). return "file://" + quote(posix_path, safe="/:") posix_path = PurePosixPath(str(db_path)).as_posix() - return "file://" + quote(posix_path, safe="/") + # Quote the filesystem BYTES, not the str: a POSIX path component that is + # not valid UTF-8 is carried in the str as ``surrogateescape`` code points, + # which ``quote(str)`` rejects with UnicodeEncodeError (the caller then + # treats the db as unreadable and every agent-backed session vanishes). + # ``Path.as_uri()`` — what master used — percent-encodes os.fsencode() + # bytes; this keeps that behavior. + return "file://" + quote_from_bytes(os.fsencode(posix_path), safe="/") def state_db_readonly_uri(db_path, platform: str | None = None) -> str: diff --git a/tests/test_pr7445_readonly_review_followups.py b/tests/test_pr7445_readonly_review_followups.py index 4239afaa..683cc072 100644 --- a/tests/test_pr7445_readonly_review_followups.py +++ b/tests/test_pr7445_readonly_review_followups.py @@ -207,6 +207,33 @@ def test_empty_authority_uri_opens_on_this_platform(tmp_path): conn.execute("INSERT INTO t VALUES (1)") +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX filesystem-bytes path") +def test_non_utf8_posix_path_still_opens_readonly(tmp_path): + """A non-UTF-8 path component must not make every agent session vanish. + + Python carries undecodable POSIX filename bytes as ``surrogateescape`` + code points; ``quote(str)`` rejects those with UnicodeEncodeError, which + the listing path swallows as "no sessions". ``Path.as_uri()`` (the pre-PR + shape) percent-encoded the fsencode() bytes, so the URI builder must too. + """ + try: + odd_dir = tmp_path / os.fsdecode(b"hermes-\xff-home") + odd_dir.mkdir() + except (OSError, UnicodeError): + pytest.skip("filesystem rejects non-UTF-8 names") + path = odd_dir / "state.db" + with closing(sqlite3.connect(str(path))) as conn: + conn.execute("CREATE TABLE t(x)") + conn.execute("INSERT INTO t VALUES (1)") + conn.commit() + uri = agent_sessions.state_db_readonly_uri(path.resolve()) + assert "%FF" in uri + with closing(agent_sessions.open_state_db_readonly(path)) as conn: + assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (1,) + with pytest.raises(sqlite3.OperationalError): + conn.execute("INSERT INTO t VALUES (2)") + + # -------------------------------------------------------------------------- # 3. The index maintenance script imports and runs without fcntl. # --------------------------------------------------------------------------