fix(agents): open agy, codex and hermes databases read-only (Issue #64)
This commit is contained in:
1 parent
3ce5d5fdc1
commit
b0eccc5301
8 files changed
+461
-56
No files matched your search
@@ -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 = {}
|
||||
|
||||
@@ -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]):
|
||||
|
||||
Reference in new issue
Block a user