2026-05-24 18:57:40 +00:00
|
|
|
"""
|
|
|
|
|
Regression test for #2762 — state_sync writes to wrong profile's state.db
|
|
|
|
|
when profile is switched via WebUI cookie.
|
|
|
|
|
|
|
|
|
|
Root cause: ``_get_state_db()`` relied on TLS-based
|
|
|
|
|
``get_active_hermes_home()`` to pick the DB path. TLS gets set on the HTTP
|
|
|
|
|
thread by the cookie middleware, but the agent streaming worker thread that
|
|
|
|
|
calls ``sync_session_usage`` does NOT inherit that TLS, so the lookup falls
|
|
|
|
|
through to the process-global active profile and writes to the wrong DB.
|
|
|
|
|
|
|
|
|
|
Fix: ``_get_state_db(profile=...)`` accepts an explicit profile name and
|
|
|
|
|
resolves *that* profile's home directly via
|
|
|
|
|
``_resolve_profile_home_for_name``. Callers that know the session's profile
|
|
|
|
|
(e.g. ``sync_session_usage`` after streaming completes) pass it explicitly,
|
|
|
|
|
avoiding the TLS race.
|
|
|
|
|
|
|
|
|
|
These tests pin:
|
|
|
|
|
1. ``_get_state_db(profile='X')`` resolves X's home, not the active profile's.
|
|
|
|
|
2. ``sync_session_usage(..., profile='X')`` writes to X's state.db only,
|
|
|
|
|
even when the global active profile is set to Y.
|
|
|
|
|
3. ``sync_session_usage`` with no profile kwarg falls back to the old
|
|
|
|
|
TLS behavior (so existing callers don't regress).
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import sqlite3
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Make sure we can import the api package the same way the server does.
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def two_profile_homes(tmp_path, monkeypatch):
|
|
|
|
|
"""Stand up two minimal profile homes with state.db initialized via
|
|
|
|
|
``hermes_state.SessionDB`` itself (so the schema matches what the
|
|
|
|
|
production code expects — `sync_session_usage` does a raw-SQL
|
|
|
|
|
UPDATE of `message_count`, which hand-rolled schemas could miss).
|
|
|
|
|
Per Copilot review on PR #2827.
|
|
|
|
|
"""
|
|
|
|
|
# Skip the fixture cleanly if the production package isn't importable
|
|
|
|
|
# in this env — same gate the tests below use.
|
|
|
|
|
pytest.importorskip("hermes_state")
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
|
|
|
|
hiyuki_home = tmp_path / 'hiyuki'
|
|
|
|
|
maiko_home = tmp_path / 'maiko'
|
|
|
|
|
for home in (hiyuki_home, maiko_home):
|
|
|
|
|
home.mkdir(parents=True)
|
|
|
|
|
# Touch state.db then open via SessionDB so its own constructor
|
|
|
|
|
# runs whatever schema init / migration the production code
|
|
|
|
|
# would see at runtime. Then close immediately — each test
|
|
|
|
|
# opens its own handle through the production code path.
|
|
|
|
|
(home / 'state.db').touch()
|
|
|
|
|
SessionDB(home / 'state.db').close()
|
|
|
|
|
|
|
|
|
|
# Stub api.profiles to return our temp paths
|
|
|
|
|
import api.profiles as profiles_mod
|
|
|
|
|
|
|
|
|
|
def fake_resolve(name):
|
|
|
|
|
if name == 'hiyuki':
|
|
|
|
|
return hiyuki_home
|
|
|
|
|
if name == 'maiko':
|
|
|
|
|
return maiko_home
|
|
|
|
|
raise LookupError(name)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(profiles_mod, '_resolve_profile_home_for_name', fake_resolve)
|
|
|
|
|
# Active profile is hiyuki — the WRONG one for tests that pass profile='maiko'
|
|
|
|
|
monkeypatch.setattr(profiles_mod, 'get_active_hermes_home', lambda: hiyuki_home)
|
|
|
|
|
|
|
|
|
|
return {'hiyuki': hiyuki_home, 'maiko': maiko_home}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_session(db_path: Path, session_id: str):
|
|
|
|
|
if not db_path.exists():
|
|
|
|
|
return None
|
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
|
|
|
try:
|
2026-05-24 19:17:59 +00:00
|
|
|
# Real state.db schema (see api/state_sync.py + hermes_cli StateDB):
|
|
|
|
|
# `sessions` table has `id` as PRIMARY KEY (not session_id). Use real
|
|
|
|
|
# column names so the test queries the actual schema.
|
2026-05-24 18:57:40 +00:00
|
|
|
cur = conn.execute(
|
2026-05-24 19:17:59 +00:00
|
|
|
"SELECT id AS session_id, title, input_tokens, output_tokens "
|
|
|
|
|
"FROM sessions WHERE id = ?",
|
2026-05-24 18:57:40 +00:00
|
|
|
(session_id,),
|
|
|
|
|
)
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
return row
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_state_db_honors_explicit_profile_kwarg(two_profile_homes):
|
|
|
|
|
"""_get_state_db(profile='maiko') resolves to maiko's home, NOT
|
|
|
|
|
the active profile (hiyuki)."""
|
|
|
|
|
from api.state_sync import _get_state_db
|
|
|
|
|
|
|
|
|
|
# Some installs ship without the hermes_state package; the function
|
|
|
|
|
# returns None gracefully and there's nothing to assert.
|
|
|
|
|
try:
|
|
|
|
|
import hermes_state # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("hermes_state package not available in this test env")
|
|
|
|
|
|
|
|
|
|
db = _get_state_db(profile='maiko')
|
|
|
|
|
if db is None:
|
|
|
|
|
pytest.skip("SessionDB could not open the test db (env issue)")
|
|
|
|
|
# We don't have a public accessor for the underlying path on SessionDB,
|
|
|
|
|
# but writing through it and reading the raw file should work.
|
|
|
|
|
db.ensure_session(session_id='probe-2762', source='webui', model='test')
|
|
|
|
|
try:
|
|
|
|
|
db.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# maiko's state.db should have the row; hiyuki's should not.
|
|
|
|
|
assert _read_session(two_profile_homes['maiko'] / 'state.db', 'probe-2762') is not None, \
|
|
|
|
|
"session was not written to maiko's state.db"
|
|
|
|
|
assert _read_session(two_profile_homes['hiyuki'] / 'state.db', 'probe-2762') is None, \
|
|
|
|
|
"session leaked into hiyuki's state.db — TLS-fallback regressed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_session_usage_writes_only_to_named_profile(two_profile_homes):
|
|
|
|
|
"""sync_session_usage(..., profile='maiko') is the actual scenario from
|
|
|
|
|
the streaming worker thread post-#2762. The write must land in maiko's
|
|
|
|
|
state.db only, regardless of what the global active profile is."""
|
|
|
|
|
try:
|
|
|
|
|
import hermes_state # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("hermes_state package not available in this test env")
|
|
|
|
|
|
|
|
|
|
from api.state_sync import sync_session_usage
|
|
|
|
|
|
|
|
|
|
sync_session_usage(
|
|
|
|
|
session_id='2762-regression',
|
|
|
|
|
input_tokens=42,
|
|
|
|
|
output_tokens=17,
|
|
|
|
|
estimated_cost=0.001,
|
|
|
|
|
model='test-model',
|
|
|
|
|
title='2762 regression test',
|
|
|
|
|
message_count=3,
|
|
|
|
|
profile='maiko',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', '2762-regression')
|
|
|
|
|
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', '2762-regression')
|
|
|
|
|
|
|
|
|
|
assert maiko_row is not None, \
|
|
|
|
|
"sync_session_usage(profile='maiko') did not write to maiko's state.db"
|
|
|
|
|
assert hiyuki_row is None, \
|
|
|
|
|
"sync_session_usage(profile='maiko') leaked into hiyuki's state.db — #2762 regression"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_session_usage_without_profile_kwarg_uses_active(two_profile_homes):
|
|
|
|
|
"""Backward compatibility: when called without a profile kwarg (the
|
|
|
|
|
pre-#2762 call shape), the function falls back to the active profile
|
|
|
|
|
(here: hiyuki). Existing callers should not regress."""
|
|
|
|
|
try:
|
|
|
|
|
import hermes_state # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("hermes_state package not available in this test env")
|
|
|
|
|
|
|
|
|
|
from api.state_sync import sync_session_usage
|
|
|
|
|
|
|
|
|
|
sync_session_usage(
|
|
|
|
|
session_id='legacy-call-shape',
|
|
|
|
|
input_tokens=1,
|
|
|
|
|
output_tokens=2,
|
|
|
|
|
model='legacy',
|
|
|
|
|
title='legacy',
|
|
|
|
|
message_count=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', 'legacy-call-shape')
|
|
|
|
|
assert hiyuki_row is not None, \
|
|
|
|
|
"sync_session_usage() without profile= regressed: did not write to active profile's state.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_explicit_profile_returns_none_not_falls_back(two_profile_homes):
|
|
|
|
|
"""Copilot review of PR #2827: when ``profile`` is explicit and
|
|
|
|
|
resolution fails (e.g. typoed profile name, IO error), the
|
|
|
|
|
function MUST return None rather than silently fall back to
|
|
|
|
|
HERMES_HOME and write to the wrong DB. That fallback would
|
|
|
|
|
re-introduce the exact #2762 symptom (writes leaking into the
|
|
|
|
|
active profile).
|
|
|
|
|
|
|
|
|
|
The fixture's `fake_resolve` raises LookupError for any name
|
|
|
|
|
that isn't 'hiyuki' or 'maiko', so passing 'does-not-exist'
|
|
|
|
|
here exercises the failure path.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
import hermes_state # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("hermes_state package not available in this test env")
|
|
|
|
|
|
|
|
|
|
from api.state_sync import sync_session_usage
|
|
|
|
|
|
|
|
|
|
# Passing an unknown profile name MUST NOT cause a write to land in
|
|
|
|
|
# hiyuki (the active profile's home). If we leaked there, that's
|
|
|
|
|
# the exact bug we're guarding against.
|
|
|
|
|
sync_session_usage(
|
|
|
|
|
session_id='unknown-profile-probe',
|
|
|
|
|
input_tokens=99,
|
|
|
|
|
output_tokens=99,
|
|
|
|
|
model='probe',
|
|
|
|
|
title='probe',
|
|
|
|
|
message_count=1,
|
|
|
|
|
profile='does-not-exist',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', 'unknown-profile-probe')
|
|
|
|
|
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', 'unknown-profile-probe')
|
|
|
|
|
|
|
|
|
|
assert hiyuki_row is None, (
|
|
|
|
|
"unknown explicit profile leaked write into hiyuki's state.db — #2762 regression"
|
|
|
|
|
)
|
|
|
|
|
assert maiko_row is None, (
|
|
|
|
|
"unknown explicit profile somehow ended up in maiko's state.db"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("bad_name", [
|
|
|
|
|
"../etc", # path traversal attempt
|
|
|
|
|
"Foo Bar", # space — invalid chars
|
|
|
|
|
"FOO", # uppercase — invalid per _PROFILE_ID_RE
|
|
|
|
|
"-leading-dash", # leading dash — invalid per regex (must start [a-z0-9])
|
|
|
|
|
"_underscore", # leading underscore — invalid per regex
|
|
|
|
|
"a" * 100, # too long (> 64 chars)
|
|
|
|
|
"", # empty string is handled by _is_root_profile, separate case
|
|
|
|
|
])
|
|
|
|
|
def test_invalid_profile_name_refused_not_falls_back(two_profile_homes, bad_name):
|
|
|
|
|
"""Per PR #2827 maintainer review: an invalid-but-non-malicious
|
|
|
|
|
profile name on the explicit-profile path must be REFUSED, not
|
|
|
|
|
quietly routed to the default state.db.
|
|
|
|
|
|
|
|
|
|
Before this defense, ``_resolve_profile_home_for_name`` would return
|
|
|
|
|
``_DEFAULT_HERMES_HOME`` for any name failing ``_PROFILE_ID_RE``
|
|
|
|
|
without raising — which is the exact #2762 leak symptom with a
|
|
|
|
|
different trigger. The new regex check up-front turns that quiet
|
|
|
|
|
leak into an explicit "refuse + log + return None" so the
|
|
|
|
|
explicit-path contract is "write to the EXACT named profile, or
|
|
|
|
|
write nowhere."
|
|
|
|
|
|
|
|
|
|
The empty string is intentionally in the parametrize set because
|
|
|
|
|
we want to confirm it's refused — ``_is_root_profile('')`` returns
|
|
|
|
|
False (per ``api/profiles.py:216-217`` it short-circuits on falsy
|
|
|
|
|
input), so an empty explicit profile fails both the
|
|
|
|
|
``_is_root_profile`` check and the regex, and the contract refuses
|
|
|
|
|
the write. That's the expected behavior — an empty explicit name
|
|
|
|
|
is itself a bug at the caller, not "I want the default."
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
import hermes_state # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("hermes_state package not available in this test env")
|
|
|
|
|
|
|
|
|
|
from api.state_sync import sync_session_usage
|
|
|
|
|
|
|
|
|
|
sync_session_usage(
|
|
|
|
|
session_id=f'invalid-name-probe-{abs(hash(bad_name))}',
|
|
|
|
|
input_tokens=99,
|
|
|
|
|
output_tokens=99,
|
|
|
|
|
model='probe',
|
|
|
|
|
title='probe',
|
|
|
|
|
message_count=1,
|
|
|
|
|
profile=bad_name,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sid = f'invalid-name-probe-{abs(hash(bad_name))}'
|
|
|
|
|
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', sid)
|
|
|
|
|
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', sid)
|
|
|
|
|
|
|
|
|
|
# All invalid names (including empty string) MUST be refused —
|
|
|
|
|
# no row should appear in either profile's state.db.
|
|
|
|
|
assert hiyuki_row is None, (
|
|
|
|
|
f"invalid profile name {bad_name!r} leaked write into hiyuki's "
|
|
|
|
|
"state.db — defense missed; #2762 regression"
|
|
|
|
|
)
|
|
|
|
|
assert maiko_row is None, (
|
|
|
|
|
f"invalid profile name {bad_name!r} somehow ended up in maiko's state.db"
|
|
|
|
|
)
|