Files
hermes-webui/tests/test_issue3238_orphaned_cli_sidecar_prune.py
T
b4c434fb65 Release v0.51.565 — API-server sidecar prune (#4619) + mic insecure-origin message (#4621) (#4639)
* stage batch #4619+#4621 (franksong2702): API-server sidecar orphan-prune + mic insecure-origin message

#4619 (fixes #4591): widen the #3238 orphan-prune to also reconcile API-server
imported sidecars (api/routes.py _is_api_server_sidecar_row). Webui-source guard
intact, fail-closed probe (agent_session_rows_existing degrades to assume-present).
#4621 (fixes #4571): detect insecure non-local http origins for voice dictation +
hands-free, show mic_insecure_origin message, preflight before capture. mic_insecure_origin
added to all 13 locale blocks (zh-Hant real translation + English TODO fallbacks).
Disjoint files (backend routes vs frontend boot/i18n). Code byte-faithful from PR heads.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

* Release v0.51.565 — Release TX (API-server sidecar prune #4619 + mic insecure-origin #4621)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-21 16:50:32 -07:00

307 lines
11 KiB
Python

"""Regression coverage for orphaned imported sidecar pruning.
#3238: WebUI does not sync when CLI sessions are deleted outside WebUI.
#4591: API-server sidecars are read-only and accumulated indefinitely after
their backing state.db row was deleted.
When a CLI/agent session is clicked in the WebUI sidebar it gets a WebUI-owned
sidecar (`webui/sessions/<id>.json` + an `_index.json` row) so it can render and
be reopened. From then on `all_sessions()` returns it independently of the agent
`state.db`. If the user later deletes that session from the CLI / local Hermes
storage, nothing prunes the orphaned sidecar, so the stale row lingers in the
sidebar forever — there is no WebUI delete affordance for CLI rows.
The fix probes `state.db` directly via `agent_session_rows_existing()` (an
exact, uncapped existence check) and prunes the sidecar only when the backing
row is genuinely gone. It must NOT rely on the session's presence in
`get_cli_sessions()`, which caps at `CLI_VISIBLE_SESSION_LIMIT` (20) — an
existing session can fall out of that window and look deleted.
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
def _make_state_db(path: Path, session_ids):
"""Create a minimal agent state.db with a `sessions` table + given ids."""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"started_at REAL, last_activity REAL)"
)
for sid in session_ids:
conn.execute(
"INSERT INTO sessions (id, source, started_at, last_activity) "
"VALUES (?, 'cli', 0, 0)",
(sid,),
)
conn.commit()
finally:
conn.close()
def test_agent_session_row_exists_true_for_present_row(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["sess-present"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("sess-present") is True
def test_agent_session_row_exists_false_for_deleted_row(tmp_path, monkeypatch):
"""The core fix: a session id that is NOT in state.db is reported gone."""
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["other-session"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("sess-deleted") is False
def test_agent_session_row_exists_safe_true_when_db_missing(tmp_path, monkeypatch):
"""No agent DB on this instance -> never claim a row is gone (no data loss)."""
from api import models
monkeypatch.setattr(
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
)
assert models.agent_session_row_exists("anything") is True
def test_agent_session_row_exists_empty_id_is_false(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["x"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("") is False
assert models.agent_session_row_exists(None) is False
def test_agent_session_row_exists_handles_missing_sessions_table(tmp_path, monkeypatch):
"""A state.db without a `sessions` table degrades to safe-True."""
from api import models
home = tmp_path / "home"
home.mkdir()
db = home / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE unrelated (x TEXT)")
conn.commit()
conn.close()
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
assert models.agent_session_row_exists("whatever") is True
def test_agent_session_rows_existing_returns_present_subset(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["sess-a", "sess-b"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
existing = models.agent_session_rows_existing(
["sess-a", "sess-b", "sess-missing", "", None]
)
assert existing == frozenset({"sess-a", "sess-b"})
def test_agent_session_rows_existing_safe_when_db_missing(tmp_path, monkeypatch):
from api import models
monkeypatch.setattr(
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
)
wanted = ["orphan-a", "orphan-b"]
assert models.agent_session_rows_existing(wanted) == frozenset(wanted)
def test_agent_session_rows_existing_batches_over_500_ids(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
ids = [f"sess-{i:04d}" for i in range(600)]
_make_state_db(home / "state.db", ids[:300])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
existing = models.agent_session_rows_existing(ids)
assert existing == frozenset(ids[:300])
def test_agent_session_rows_existing_normalizes_whitespace_in_probe_ids(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["cli-padded"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
existing = models.agent_session_rows_existing([" cli-padded "])
assert existing == frozenset({"cli-padded"})
# ── Orphan-prune decision predicate (mirrors the sidebar merge-loop guard) ──
def _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn):
"""Replicates the routes.py merge-loop predicate so the decision logic is
covered without standing up the full HTTP sidebar endpoint."""
from api.agent_sessions import is_cli_session_row
from api.routes import _session_source_is_webui
sid = row.get("session_id")
return bool(
sid
and is_cli_session_row(row)
and not _session_source_is_webui(row)
and sid not in cli_by_id
and not exists_fn(sid)
)
def test_orphaned_imported_cli_sidecar_is_pruned():
"""import-sourced CLI row, not in cli_by_id, backing state.db row gone."""
row = {"session_id": "cli-orphan", "source_tag": "cli", "is_cli_session": True}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is True
def test_native_webui_session_with_cli_ancestor_is_never_pruned():
"""Regression guard: a WebUI-native row must survive even if absent from
cli_by_id and its (ancestor) id isn't in state.db."""
row = {
"session_id": "webui-native",
"source_tag": "webui",
"session_source": "webui",
"is_cli_session": False,
}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is False
def test_cli_row_still_backed_in_state_db_is_not_pruned():
"""Falls out of the recent-20 window (absent from cli_by_id) BUT still exists
in state.db -> must NOT be pruned. This is the cap-window false-positive the
direct state.db probe defends against."""
row = {"session_id": "cli-old", "source_tag": "cli", "is_cli_session": True}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: True) is False
def test_cli_row_present_in_cli_by_id_is_not_pruned():
"""Still in the live CLI list -> obviously not orphaned."""
row = {"session_id": "cli-live", "source_tag": "cli", "is_cli_session": True}
cli_by_id = {"cli-live": {"session_id": "cli-live"}}
assert _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn=lambda sid: False) is False
def _payload_for_rows(monkeypatch, rows, existing_ids):
import api.routes as routes
pruned = []
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows))
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [])
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _sessions: False,
)
monkeypatch.setattr(
routes,
"agent_session_rows_existing",
lambda ids, profile=None: frozenset(existing_ids),
)
monkeypatch.setattr(routes, "prune_session_from_index", lambda sid: pruned.append(sid))
payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
return payload, pruned
def test_orphaned_api_server_sidecar_is_pruned_from_sidebar_payload(monkeypatch):
"""API-server sidecars take the same exact state.db orphan prune path."""
row = {
"session_id": "api-orphan",
"title": "API Session",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 2,
"read_only": True,
"source_tag": "api_server",
"raw_source": "api_server",
"session_source": "api",
"source_label": "API",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[])
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["api-orphan"]
def test_api_server_sidecar_with_backing_state_row_is_retained(monkeypatch):
"""Absence from get_cli_sessions() alone is not enough to prune API rows."""
row = {
"session_id": "api-live",
"title": "API Session",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 2,
"read_only": True,
"source_tag": "api_server",
"raw_source": "api_server",
"session_source": "api",
"source_label": "API",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=["api-live"])
assert [session["session_id"] for session in payload["sessions"]] == ["api-live"]
assert pruned == []
def test_webui_owned_session_with_api_metadata_is_not_pruned(monkeypatch):
"""A native WebUI row must stay safe even if stale metadata mentions API."""
row = {
"session_id": "webui-native",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 2,
"read_only": False,
"source_tag": "webui",
"raw_source": "api_server",
"session_source": "webui",
"source_label": "API",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[])
assert [session["session_id"] for session in payload["sessions"]] == ["webui-native"]
assert pruned == []