fix(agents): open agy, codex and hermes databases read-only (Issue #64)

This commit is contained in:
Godopu committed 2026-10-19 23:12:55 +09:00
1 parent 3ce5d5fdc1
commit b0eccc5301
8 files changed
+461 -56

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
import os, sys, json, sqlite3, shutil
import os, sys, json, shutil
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, conversation_in_use
class AgyAgentAdapter(BaseAgentAdapter):
@property
@@ -71,7 +71,7 @@ class AgyAgentAdapter(BaseAgentAdapter):
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = sqlite3.connect(path)
conn = _ro_connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
@@ -1,6 +1,6 @@
import os, json, glob, sqlite3, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter):
@@ -77,7 +77,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
if not db or not os.path.exists(db):
return None, None
try:
conn = sqlite3.connect(db)
conn = _ro_connect(db)
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
conn.close()
if row and row[0] and os.path.exists(row[0]):
@@ -192,7 +192,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
conn = _ro_connect(db)
cur = conn.cursor()
cur.execute("PRAGMA table_info(threads)")
cols = {r[1] for r in cur.fetchall()}
@@ -1,6 +1,6 @@
import os, sys, sqlite3
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect
from lib_py.verify_session import workspace_key
class HermesAgentAdapter(BaseAgentAdapter):
@@ -48,7 +48,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
if not os.path.exists(hdb):
return False
try:
conn = sqlite3.connect(hdb)
conn = _ro_connect(hdb)
r = conn.execute("SELECT cwd, started_at FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
@@ -97,7 +97,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
if not os.path.exists(hdb):
return []
try:
conn = sqlite3.connect(hdb)
conn = _ro_connect(hdb)
if ctx.epoch:
rows = conn.execute(
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
@@ -1,7 +1,6 @@
import os, sys, sqlite3, shutil, subprocess, tempfile
from urllib.parse import quote
import os, sys, sqlite3, shutil, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming, conversation_in_use
from lib_py.verify_session import workspace_key
# Tables a 1.x opencode wrote and a migration to 2.x keeps but no longer
@@ -10,31 +9,6 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
("session", "id"), ("sessions", "id"))
def _ro_connect(db: str):
"""A read that neither creates nor removes anything beside db (Issue #60).
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
nothing to apply, so the file is read as immutable. A -wal without its
-shm (left behind - no writer runs without one) still holds committed
rows: they are read from a private copy, where SQLite may rebuild the
index, loaded into memory."""
path = os.path.abspath(db)
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
if wal and shm:
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
if not wal:
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
mem = sqlite3.connect(":memory:")
with tempfile.TemporaryDirectory(prefix="mam-opencode-") as tmp:
copy = os.path.join(tmp, "opencode.db")
shutil.copyfile(path, copy)
shutil.copyfile(path + "-wal", copy + "-wal")
src = sqlite3.connect(copy)
try:
src.backup(mem)
finally:
src.close()
return mem
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
_DB_PATHS = {}
+29 -3
View File
@@ -1,6 +1,6 @@
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3, shutil, subprocess
import os, json, sqlite3, shutil, subprocess, tempfile
from typing import Optional, Dict, Any, List
from urllib.parse import quote
from lib_py.paths import resolve_home
@@ -108,15 +108,41 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
return sorted(found)
def _ro_connect(db: str):
"""A read that neither creates nor removes anything beside db (Issues #60, #64).
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
nothing to apply, so the file is read as immutable. A -wal without its
-shm (left behind - no writer runs without one) still holds committed
rows: they are read from a private copy, where SQLite may rebuild the
index, loaded into memory."""
path = os.path.abspath(db)
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
if wal and shm:
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
if not wal:
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
mem = sqlite3.connect(":memory:")
with tempfile.TemporaryDirectory(prefix="mam-ro-") as tmp:
copy = os.path.join(tmp, os.path.basename(path) or "state.db")
shutil.copyfile(path, copy)
shutil.copyfile(path + "-wal", copy + "-wal")
src = sqlite3.connect(copy)
try:
src.backup(mem)
finally:
src.close()
return mem
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
conversation in one of `columns`, or in the id column of an `id_tables`
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
`connect(db)` opens the database (default: a mode=ro URI)."""
`connect(db)` opens the database (default: `_ro_connect`)."""
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
return []
try:
conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
conn = (connect or _ro_connect)(db)
except Exception:
return []
found = []
@@ -420,7 +420,7 @@ import yaml
from lib_py.verify_session import verify_session_uuid, workspace_key
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.agents.base import DiscoveryContext, _ro_connect
def _slug(path):
if not path:
@@ -862,7 +862,7 @@ for s in d.get('herdr_sessions', []):
valid_candidates = []
try:
conn = sqlite3.connect(hdb)
conn = _ro_connect(hdb)
rows = conn.execute(
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
(cwd, epoch_threshold)
@@ -951,7 +951,6 @@ for s in d.get('herdr_sessions', []):
valid_candidates = []
try:
# read-only: nothing is created or removed beside the agent's db (Issue #60)
from lib_py.agents.adapters.opencode import _ro_connect
conn = _ro_connect(odb)
cursor = conn.cursor()
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
@@ -1046,7 +1045,7 @@ for s in d.get('herdr_sessions', []):
rows = []
if state_db and os.path.exists(state_db):
try:
conn = sqlite3.connect(state_db)
conn = _ro_connect(state_db)
cols = [c[1] for c in conn.execute("PRAGMA table_info(threads)").fetchall()]
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
if order_col:
@@ -87,8 +87,8 @@ def resume_on_disk(s):
if not os.path.exists(hdb):
return 'MISSING'
try:
import sqlite3
conn = sqlite3.connect(hdb)
from lib_py.agents.base import _ro_connect
conn = _ro_connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
conn.close()
return 'yes' if r else 'MISSING'
@@ -107,13 +107,12 @@ def resume_on_disk(s):
if u:
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.agents.base import DiscoveryContext, _ro_connect
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
if not odb or not os.path.exists(odb):
return 'MISSING'
try:
# read-only: nothing is created or removed beside the agent's db (Issue #60)
from lib_py.agents.adapters.opencode import _ro_connect
conn = _ro_connect(odb)
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
conn.close()
@@ -129,8 +128,8 @@ def resume_on_disk(s):
if db_matches:
db = max(db_matches, key=os.path.getmtime)
try:
import sqlite3
conn = sqlite3.connect(db)
from lib_py.agents.base import _ro_connect
conn = _ro_connect(db)
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
conn.close()
if r and r[0] and os.path.exists(r[0]):
@@ -288,8 +287,8 @@ def resume_on_disk(s):
if not os.path.exists(hdb):
return 'MISSING'
try:
import sqlite3
conn = sqlite3.connect(hdb)
from lib_py.agents.base import _ro_connect
conn = _ro_connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
conn.close()
return 'yes' if r else 'MISSING'
@@ -308,13 +307,12 @@ def resume_on_disk(s):
if u:
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.agents.base import DiscoveryContext, _ro_connect
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
if not odb or not os.path.exists(odb):
return 'MISSING'
try:
# read-only: nothing is created or removed beside the agent's db (Issue #60)
from lib_py.agents.adapters.opencode import _ro_connect
conn = _ro_connect(odb)
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
conn.close()
@@ -330,8 +328,8 @@ def resume_on_disk(s):
if db_matches:
db = max(db_matches, key=os.path.getmtime)
try:
import sqlite3
conn = sqlite3.connect(db)
from lib_py.agents.base import _ro_connect
conn = _ro_connect(db)
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
conn.close()
if r and r[0] and os.path.exists(r[0]):