Merge pull request #1682 from nesquena/stage-299
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.2 — 3-PR follow-up + sidebar scroll hotfix
This commit is contained in:
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,5 +1,50 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sidebar scroll jumps back to 0 on small lists (≤80 sessions)** — PR #1669 added DOM virtualization to `renderSessionListFromCache()` with two flaws for lists below the virtualization threshold: (1) the unconditional scroll listener triggered a full DOM rebuild on every rAF, and (2) `scrollTop` was only restored when `virtualWindow.virtualized` was true (i.e. total > 80 rows). For lists ≤ 80 rows, `scrollTop` dropped to 0 on every scroll event, producing a "scroll keeps jumping back" feel. Two-part fix: (a) always restore `scrollTop` when `listScrollTopBeforeRender > 0` regardless of virtualized flag, (b) short-circuit `_scheduleSessionVirtualizedRender` when total ≤ `SESSION_VIRTUAL_THRESHOLD_ROWS` (saves the wasteful rebuild and is belt-and-suspenders defense). Live verified: production v0.51.1 confirmed broken (scrollTop drops to 0 within 100ms); v0.51.2 confirmed working (holds at 500 across 600ms+). 3 regression tests pin both fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #1664** by @Michaelyklam — LLM Wiki status panel (closes #1257). New read-only Insights card showing wiki state (entries, pages, raw files, last updated, last writer) with traffic-light status badge ("Available" / "Empty" / "Unavailable" / "Error"). New `GET /api/wiki/status` endpoint reads `WIKI_PATH` env var or `skills.config.wiki.path` config, returns metadata-only counts. `loadInsights()` parallelizes the wiki status fetch with the existing `/api/insights` call via `Promise.all`, with a `.catch` fallback so wiki failures don't break Insights.
|
||||
- **PR #1662** by @Michaelyklam — Logs tab MVP (closes #1455). New top-level Logs tab in nav rail. Allowlisted server-side log file viewer (`agent` / `errors` / `gateway`) with severity highlighting (info/warning/error/debug), tail size selector (100/200/500/1000 lines), auto-refresh, copy-all. New `GET /api/logs` endpoint with strict allowlist + path-traversal guard + bounded 4 MiB tail window. 8 i18n locale entries added.
|
||||
- **PR #1587** by @franksong2702 — Filter low-value CLI agent sessions (refs #1013). Source-aware sidebar visibility rules for imported CLI agent sessions: hides empty CLI rows; hides default/untitled CLI rows with fewer than 2 user turns; keeps explicitly-titled CLI sessions; keeps compression-lineage CLI sessions. Treats true CLI-origin rows as external/imported in action menu (keeps pin/move/archive/restore, hides duplicate/delete). New `_isCliSession(session)` helper in static/sessions.js for source classification.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- Full pytest sequential pass: 4429 → **4457 passing** (+28). 0 regressions.
|
||||
- JS syntax check on 6 modified `.js` files via `node -c`: all clean.
|
||||
- Python syntax check on 9 modified `.py` files: all clean.
|
||||
- QA harness: 20 pytest + 11 browser API + `/health` probe — ALL CHECKS PASSED.
|
||||
- Browser-driven smoke test on 56-session sidebar:
|
||||
- Logs tab: panel renders with file/tail selectors; 4 test log lines (INFO/WARNING/ERROR/DEBUG) all rendered with correct severity classes.
|
||||
- LLM Wiki card: renders in Insights tab with proper "Unavailable" state and 6-grid metadata layout. Existing Insights chart (#1668) renders unaffected.
|
||||
- `_isCliSession` helper: 6/6 test cases correct (null, empty object, session_source=cli → true, raw_source=CLI → true, source_label=cli → true, raw_source=web → false).
|
||||
- Sidebar scroll: scrollTop=500 holds steady across 100/300/600ms; scroll-to-bottom (1986) holds across 600ms.
|
||||
- Path traversal: `/api/logs?file=../../etc/passwd` correctly returns HTTP 400.
|
||||
- Independent review: Opus advisor on stage-298 diff (1336 LOC). 6/6 verification questions resolved cleanly: SSRF safety, path traversal, schema redaction, JS XSS prevention, scroll-fix first-render edge case, CHANGELOG handling. **Verdict: SHIP.** 0 MUST-FIX, 2 SHOULD-FIX absorbed in-release (see below).
|
||||
|
||||
### Opus-applied fixes (absorbed in-release)
|
||||
|
||||
**From stage-299 absorption (this release):**
|
||||
- **Bounded WIKI_PATH walk + forbidden-root guard** (`api/routes.py`): `_LLM_WIKI_MAX_FILES = 10000` caps `rglob` iteration in both `_llm_wiki_count_files` and `_llm_wiki_page_files` (prevents hangs on symlink loops or pathologically-large trees). `_LLM_WIKI_FORBIDDEN_ROOTS` blocklist refuses `/`, `/etc`, `/usr`, `/var`, `/opt`, `/sys`, `/proc` even if `WIKI_PATH` is misconfigured to point at them. Self-DoS prevention: `/api/wiki/status` fires on every Insights tab open via `Promise.all`, and unbounded `rglob` on a misconfigured root would block the endpoint. 6 regression tests pin the constants + behavioral guards.
|
||||
- **URL-scheme guard for `docs_url` interpolation** (`static/panels.js`): `rawDocsUrl` is regex-validated against `/^https?:\/\//i` before being interpolated into the `<a href=>` attribute. `esc()` HTML-escapes but doesn't validate URL scheme; `docs_url` is server-controlled today but the contributor scaffolded it for potential config-driven use, so future-proofs against `js:` / `data:` scheme XSS.
|
||||
|
||||
### Surgical conflict resolution
|
||||
|
||||
All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in `static/panels.js` and `static/style.css`. Resolved per-conflict surgically rather than via naive keep-both:
|
||||
|
||||
- **#1664 panels.js**: kept master's modern `_renderInsights` body (preserves the v0.51.1 chart enhancements from #1668), modified its signature to accept `wikiStatus` as 3rd parameter, AND inserted the two new wiki helper functions (`_formatLlmWikiTimestamp`, `_renderLlmWikiStatus`) before it. Verified single `_renderInsights` definition.
|
||||
- **#1664 style.css**: kept master's `.insights-card { margin-bottom: 16px }` (used by other Insights cards) and ADDED all the new `.wiki-status-*` rules. Discarded contributor's modification of `.insights-card` (would have broken #1668 chart card spacing).
|
||||
- **#1662 panels.js**: panel-list array union'd to include both `'kanban'` (v0.51.0) and `'logs'` (this PR). Large additive region: kept BOTH the master's Kanban switcher/modal block AND the contributor's Logs panel block. Patched a missing pair of closing braces (`}\n}\n`) at the boundary where the conflict marker truncated `archiveKanbanBoard`.
|
||||
- **#1662 style.css**: display-none selector union'd to include `#mainInsights, #mainLogs` AND `:not(.showing-kanban):not(.showing-logs)` chain.
|
||||
- **#1587 sessions.js**: kept master's `_isReadOnlySession` and `_sourceKeyForSession` helpers AND added the new `_isCliSession` helper. Patched a missing closing brace on `_sourceKeyForSession` introduced by conflict-marker truncation.
|
||||
|
||||
Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving `Co-authored-by:` attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.
|
||||
|
||||
|
||||
## [v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam
|
||||
|
||||
### Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
|
||||
@@ -182,7 +227,6 @@ This was a large stack of work. Massive thanks to **@ai-ag2026** for the full Ka
|
||||
### Note on closed-as-superseded
|
||||
|
||||
PR #1656 (also @Michaelyklam) was closed as superseded by #1657. Both target #1458 Bug #3, both add accept-loop heartbeat + `/health?deep=1` + 503-on-degraded. #1657 adds beyond #1656: state.db connectivity check, projects state check, FD soft-limit raise, and `docs/supervisor.md` watchdog recipe. Same author iterated; the second PR was the keeper.
|
||||
|
||||
## [v0.50.296] — 2026-05-04
|
||||
|
||||
### Fixed (3 PRs — closes #1406, #1617; refs #1362)
|
||||
@@ -448,7 +492,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
|
||||
- **Auto-fix on #1464:** ternary inversion + regression test, with `Co-authored-by: Josh Jameson` preserved.
|
||||
- **Auto-fix on stage:** widened source-string anchors in two pre-existing brittle tests broken by #1591's structural changes.
|
||||
|
||||
|
||||
## [v0.50.289] — 2026-05-03
|
||||
|
||||
### Fixed (1 PR — TCP keepalive on accepted connections — closes #1580)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.51.1 (May 04, 2026) — 4429 tests collected — 11-PR Michaelyklam batch
|
||||
> Last updated: v0.51.2 (May 04, 2026) — 4457 tests collected — 3-PR follow-up + scroll hotfix
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.51.1, May 04, 2026 — 11-PR Michaelyklam batch*
|
||||
*Total automated tests collected: 4429*
|
||||
*Last updated: v0.51.2, May 04, 2026 — 3-PR follow-up + scroll hotfix*
|
||||
*Total automated tests collected: 4457*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
@@ -14,6 +14,9 @@ MESSAGING_SOURCES = {
|
||||
'weixin',
|
||||
}
|
||||
|
||||
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
|
||||
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
|
||||
|
||||
SOURCE_LABELS = {
|
||||
'api_server': 'API',
|
||||
'cli': 'CLI',
|
||||
@@ -71,6 +74,115 @@ def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
|
||||
return f"s.{name}" if name in columns else f"{fallback} AS {name}"
|
||||
|
||||
|
||||
def _safe_lower(value) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_source_name(value: object) -> str:
|
||||
source = _safe_lower(value)
|
||||
if not source:
|
||||
return ""
|
||||
if source.endswith(" session"):
|
||||
source = source[:-len(" session")].strip()
|
||||
return source
|
||||
|
||||
|
||||
def _looks_like_default_cli_title(row: dict) -> bool:
|
||||
"""Return True when a CLI row looks like framework-generated metadata."""
|
||||
title = _safe_lower(row.get("title"))
|
||||
if not title or title == "untitled":
|
||||
return True
|
||||
if title in {"cli", "cli session"}:
|
||||
return True
|
||||
|
||||
source_candidates = {
|
||||
_normalize_source_name(row.get("source")),
|
||||
_normalize_source_name(row.get("session_source")),
|
||||
_normalize_source_name(row.get("source_tag")),
|
||||
_normalize_source_name(row.get("raw_source")),
|
||||
_normalize_source_name(row.get("source_label")),
|
||||
}
|
||||
source_candidates.discard("")
|
||||
source_candidates.add("cli")
|
||||
return any(title == f"{candidate} session" for candidate in source_candidates)
|
||||
|
||||
|
||||
def _as_positive_int(value) -> int:
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _count_user_turns(row: dict) -> int:
|
||||
user_turns = row.get("actual_user_message_count")
|
||||
if user_turns is None:
|
||||
user_turns = row.get("user_message_count")
|
||||
if user_turns is None:
|
||||
messages = row.get("messages") or []
|
||||
if isinstance(messages, list):
|
||||
return sum(
|
||||
1
|
||||
for msg in messages
|
||||
if _safe_lower(msg.get("role") if isinstance(msg, dict) else msg) == "user"
|
||||
)
|
||||
return 0
|
||||
return _as_positive_int(user_turns)
|
||||
|
||||
|
||||
def _has_cli_lineage(row: dict) -> bool:
|
||||
segment_count = _as_positive_int(row.get("_compression_segment_count"))
|
||||
return segment_count > 1 or bool(row.get("_lineage_root_id"))
|
||||
|
||||
|
||||
def is_cli_session_row(row: dict) -> bool:
|
||||
"""Return True for rows that should be treated as CLI-imported sessions."""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
source = _safe_lower(row.get("session_source"))
|
||||
if source == "messaging":
|
||||
return False
|
||||
if source == "cli":
|
||||
return True
|
||||
source_tag = _safe_lower(row.get("source_tag"))
|
||||
raw_source = _safe_lower(row.get("raw_source"))
|
||||
source_name = _safe_lower(row.get("source"))
|
||||
source_label = _safe_lower(row.get("source_label"))
|
||||
if source_tag == "cli" or raw_source == "cli" or source_name == "cli" or source_label == "cli":
|
||||
return True
|
||||
|
||||
# Legacy imported CLI rows may only be marked as CLI in sidebar metadata.
|
||||
# Keep this conservative to avoid treating messaging sessions as CLI.
|
||||
return bool(
|
||||
row.get("is_cli_session")
|
||||
and source not in MESSAGING_SOURCES
|
||||
and source_tag not in MESSAGING_SOURCES
|
||||
and raw_source not in MESSAGING_SOURCES
|
||||
and source_name not in MESSAGING_SOURCES
|
||||
and _looks_like_default_cli_title(row)
|
||||
)
|
||||
|
||||
|
||||
def is_cli_session_row_visible(row: dict) -> bool:
|
||||
"""Return whether a CLI-related row should remain visible in the sidebar."""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
if not is_cli_session_row(row):
|
||||
return True
|
||||
|
||||
message_count = _as_positive_int(row.get("actual_message_count") or row.get("message_count"))
|
||||
if message_count <= 0:
|
||||
return False
|
||||
|
||||
if _has_cli_lineage(row):
|
||||
return True
|
||||
|
||||
if not _looks_like_default_cli_title(row):
|
||||
return True
|
||||
|
||||
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
||||
|
||||
|
||||
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
"""Return True when ``child`` is the next segment of the same conversation.
|
||||
|
||||
@@ -201,7 +313,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
# touched standalone sessions — exactly the inverse of what a user
|
||||
# expects from "Show agent sessions" sorted by activity.
|
||||
for key in (
|
||||
'id', 'model', 'message_count', 'actual_message_count',
|
||||
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
|
||||
'ended_at', 'end_reason', 'last_activity',
|
||||
):
|
||||
if key in tip:
|
||||
@@ -255,6 +367,8 @@ def read_importable_agent_session_rows(
|
||||
# source column we cannot safely distinguish WebUI rows from agent rows.
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
message_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in session_cols:
|
||||
log.warning(
|
||||
"agent session listing skipped: state.db at %s has no 'source' column "
|
||||
@@ -275,6 +389,11 @@ def read_importable_agent_session_rows(
|
||||
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
||||
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
||||
platform_expr = _optional_col('platform', session_cols)
|
||||
user_message_count_expr = (
|
||||
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
|
||||
if 'role' in message_cols
|
||||
else "COUNT(m.id)"
|
||||
)
|
||||
|
||||
where_clauses = ["s.source IS NOT NULL"]
|
||||
params: list[str] = []
|
||||
@@ -301,6 +420,7 @@ def read_importable_agent_session_rows(
|
||||
{ended_expr},
|
||||
{end_reason_expr},
|
||||
COUNT(m.id) AS actual_message_count,
|
||||
{user_message_count_expr} AS actual_user_message_count,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
@@ -312,6 +432,7 @@ def read_importable_agent_session_rows(
|
||||
)
|
||||
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
|
||||
projected = [_with_normalized_source(row) for row in projected]
|
||||
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
||||
if limit is None:
|
||||
return projected
|
||||
return projected[:max(0, int(limit))]
|
||||
|
||||
@@ -21,6 +21,7 @@ from api.workspace import get_last_workspace
|
||||
from api.agent_sessions import read_importable_agent_session_rows, read_session_lineage_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CLI_VISIBLE_SESSION_LIMIT = 20
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale temp-file cleanup
|
||||
@@ -225,6 +226,12 @@ def _last_message_timestamp(messages):
|
||||
return None
|
||||
|
||||
|
||||
def _message_role(message):
|
||||
if not isinstance(message, dict):
|
||||
return ''
|
||||
return str(message.get('role', '')).strip().lower()
|
||||
|
||||
|
||||
def _find_top_level_json_key(text, key):
|
||||
"""Return the byte offset of a top-level JSON object key, if present."""
|
||||
depth = 0
|
||||
@@ -563,6 +570,9 @@ class Session:
|
||||
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
|
||||
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
|
||||
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
|
||||
'user_message_count': sum(
|
||||
1 for message in self.messages if _message_role(message) == 'user'
|
||||
) if isinstance(self.messages, list) else 0,
|
||||
'active_stream_id': self.active_stream_id,
|
||||
'pending_user_message': self.pending_user_message,
|
||||
'has_pending_user_message': has_pending_user_message,
|
||||
@@ -1507,7 +1517,12 @@ def get_cli_sessions() -> list:
|
||||
return _cron_pid_cache[0]
|
||||
|
||||
try:
|
||||
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger, exclude_sources=None):
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=CLI_VISIBLE_SESSION_LIMIT,
|
||||
log=logger,
|
||||
exclude_sources=None,
|
||||
):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
@@ -1573,6 +1588,7 @@ def get_cli_sessions() -> list:
|
||||
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
|
||||
'end_reason': row.get('end_reason'),
|
||||
'actual_message_count': row.get('actual_message_count'),
|
||||
'user_message_count': row.get('actual_user_message_count'),
|
||||
'_lineage_root_id': row.get('_lineage_root_id'),
|
||||
'_lineage_tip_id': row.get('_lineage_tip_id'),
|
||||
'_compression_segment_count': row.get('_compression_segment_count'),
|
||||
|
||||
349
api/routes.py
349
api/routes.py
@@ -22,7 +22,11 @@ import re
|
||||
from pathlib import Path
|
||||
from contextlib import closing
|
||||
from urllib.parse import parse_qs
|
||||
from api.agent_sessions import MESSAGING_SOURCES
|
||||
from api.agent_sessions import (
|
||||
MESSAGING_SOURCES,
|
||||
is_cli_session_row,
|
||||
is_cli_session_row_visible,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1185,6 +1189,44 @@ def _session_sort_timestamp(session: dict) -> float:
|
||||
) or 0.0
|
||||
|
||||
|
||||
def _is_cli_session_for_settings(session: dict) -> bool:
|
||||
"""Return True for importable CLI sessions that are safe to classify for settings."""
|
||||
if not isinstance(session, dict):
|
||||
return False
|
||||
if is_cli_session_row(session):
|
||||
return True
|
||||
|
||||
# Fallback for legacy local copies that had weak/empty metadata:
|
||||
# keep this conservative so messaging sessions do not collapse incorrectly.
|
||||
if not session.get("is_cli_session"):
|
||||
return False
|
||||
source = str(session.get("source") or "").strip().lower()
|
||||
if source in MESSAGING_SOURCES:
|
||||
return False
|
||||
title = str(session.get("title") or "").strip().lower()
|
||||
return title in ("", "untitled", "cli", "cli session") or title.endswith(" session") and (
|
||||
not source or source == "cli"
|
||||
)
|
||||
|
||||
|
||||
CLI_VISIBLE_SESSION_CAP = 20
|
||||
|
||||
|
||||
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
|
||||
"""Keep only the most recent CLI-visible sessions after filtering."""
|
||||
if cli_cap <= 0:
|
||||
return sessions
|
||||
kept = []
|
||||
cli_seen = 0
|
||||
for session in sessions:
|
||||
if _is_cli_session_for_settings(session):
|
||||
cli_seen += 1
|
||||
if cli_seen > cli_cap:
|
||||
continue
|
||||
kept.append(session)
|
||||
return kept
|
||||
|
||||
|
||||
def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict:
|
||||
"""Merge source-of-truth CLI metadata into a sidebar session row.
|
||||
|
||||
@@ -1629,8 +1671,294 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
<script src="static/login.js?v={{WEBUI_VERSION}}"></script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
# ── Logs endpoint ─────────────────────────────────────────────────────────────
|
||||
_LOG_FILE_WHITELIST = {
|
||||
"agent": "agent.log",
|
||||
"errors": "errors.log",
|
||||
"gateway": "gateway.log",
|
||||
}
|
||||
_LOG_TAIL_VALUES = {100, 200, 500, 1000}
|
||||
_LOG_DEFAULT_TAIL = 200
|
||||
_LOG_MAX_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _normalize_logs_tail(raw_tail) -> int:
|
||||
try:
|
||||
tail = int(str(raw_tail or "").strip())
|
||||
except (TypeError, ValueError):
|
||||
return _LOG_DEFAULT_TAIL
|
||||
return tail if tail in _LOG_TAIL_VALUES else _LOG_DEFAULT_TAIL
|
||||
|
||||
|
||||
def _handle_logs(handler, parsed) -> bool:
|
||||
"""Return a bounded tail window for an active-profile Hermes log file."""
|
||||
query = parse_qs(parsed.query)
|
||||
file_key = (query.get("file", ["agent"])[0] or "agent").strip().lower()
|
||||
filename = _LOG_FILE_WHITELIST.get(file_key)
|
||||
if not filename:
|
||||
return bad(handler, "Unknown log file", status=400)
|
||||
|
||||
tail = _normalize_logs_tail(query.get("tail", [None])[0])
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser()
|
||||
except Exception:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME") or (Path.home() / ".hermes")).expanduser()
|
||||
|
||||
log_dir = hermes_home / "logs"
|
||||
log_path = log_dir / filename
|
||||
try:
|
||||
# Defense in depth: the filename is hardcoded above, but keep the final
|
||||
# path anchored under the active profile's logs directory.
|
||||
if log_path.resolve(strict=False).parent != log_dir.resolve(strict=False):
|
||||
return bad(handler, "Invalid log file", status=400)
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
return j(handler, {
|
||||
"file": file_key,
|
||||
"tail": tail,
|
||||
"lines": [],
|
||||
"truncated": False,
|
||||
"total_bytes": 0,
|
||||
"mtime": None,
|
||||
"hint": f"Log file for {file_key} not found yet.",
|
||||
})
|
||||
st = log_path.stat()
|
||||
total_bytes = int(st.st_size)
|
||||
read_bytes = min(total_bytes, _LOG_MAX_BYTES)
|
||||
with log_path.open("rb") as fh:
|
||||
if total_bytes > read_bytes:
|
||||
fh.seek(total_bytes - read_bytes)
|
||||
raw = fh.read(read_bytes)
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()[-tail:]
|
||||
return j(handler, {
|
||||
"file": file_key,
|
||||
"tail": tail,
|
||||
"lines": lines,
|
||||
"truncated": total_bytes > read_bytes,
|
||||
"total_bytes": total_bytes,
|
||||
"mtime": st.st_mtime,
|
||||
"hint": "",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to read whitelisted log file %s", file_key)
|
||||
return bad(handler, _sanitize_error(exc), status=500)
|
||||
|
||||
# ── Insights endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
_LLM_WIKI_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki"
|
||||
_LLM_WIKI_PAGE_DIRS = ("entities", "concepts", "comparisons", "queries")
|
||||
|
||||
|
||||
def _llm_wiki_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return Path(get_active_hermes_home()).expanduser()
|
||||
except Exception:
|
||||
return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||||
|
||||
|
||||
def _llm_wiki_env_file_path(hermes_home: Path) -> str | None:
|
||||
env_path = hermes_home / ".env"
|
||||
if not env_path.exists() or not env_path.is_file():
|
||||
return None
|
||||
try:
|
||||
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
if key.strip() != "WIKI_PATH":
|
||||
continue
|
||||
value = value.strip().strip('"').strip("'")
|
||||
return value or None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _llm_wiki_get_config_path_value(config: dict, dotted_key: str) -> str | None:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
if dotted_key in config and config.get(dotted_key):
|
||||
return str(config.get(dotted_key))
|
||||
cur = config
|
||||
for part in dotted_key.split("."):
|
||||
if not isinstance(cur, dict) or part not in cur:
|
||||
return None
|
||||
cur = cur[part]
|
||||
return str(cur) if cur else None
|
||||
|
||||
|
||||
def _llm_wiki_config_path() -> str | None:
|
||||
try:
|
||||
from api.config import get_config as _get_cfg
|
||||
cfg = _get_cfg()
|
||||
except Exception:
|
||||
return None
|
||||
return (
|
||||
_llm_wiki_get_config_path_value(cfg, "skills.config.wiki.path")
|
||||
or _llm_wiki_get_config_path_value(cfg, "wiki.path")
|
||||
)
|
||||
|
||||
|
||||
# Cap WIKI walks to prevent self-DoS if WIKI_PATH points at /, /etc, /home, etc.
|
||||
# Real LLM wikis have under a few thousand files; 10k is generous and catches misconfig.
|
||||
_LLM_WIKI_MAX_FILES = 10000
|
||||
# Refuse to walk these system roots even if explicitly configured.
|
||||
_LLM_WIKI_FORBIDDEN_ROOTS = frozenset(
|
||||
str(Path(p).expanduser().resolve()) for p in ("/", "/etc", "/usr", "/var", "/opt", "/sys", "/proc")
|
||||
)
|
||||
|
||||
|
||||
def _llm_wiki_resolve_path() -> tuple[Path, str, bool]:
|
||||
hermes_home = _llm_wiki_active_hermes_home()
|
||||
raw = os.getenv("WIKI_PATH") or _llm_wiki_env_file_path(hermes_home)
|
||||
source = "WIKI_PATH" if raw else "default"
|
||||
configured = bool(raw)
|
||||
if not raw:
|
||||
raw = _llm_wiki_config_path()
|
||||
if raw:
|
||||
source = "skills.config.wiki.path"
|
||||
configured = True
|
||||
if not raw:
|
||||
raw = "~/wiki"
|
||||
return Path(os.path.expandvars(raw)).expanduser(), source, configured
|
||||
|
||||
|
||||
def _llm_wiki_safe_iso(ts: float | None) -> str | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _llm_wiki_count_files(root: Path) -> int:
|
||||
if not root.exists() or not root.is_dir():
|
||||
return 0
|
||||
# Defense in depth: refuse to walk forbidden system roots even if WIKI_PATH
|
||||
# was set to one. The endpoint is auth-gated but a misconfigured server
|
||||
# shouldn't self-DoS by rglob'ing all of /etc on every Insights load.
|
||||
try:
|
||||
if str(root.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
count = 0
|
||||
iterated = 0
|
||||
for item in root.rglob("*"):
|
||||
iterated += 1
|
||||
if iterated > _LLM_WIKI_MAX_FILES:
|
||||
break # bounded — prevents hangs on symlink loops or huge trees
|
||||
try:
|
||||
if item.is_file() and not any(part.startswith(".") for part in item.relative_to(root).parts):
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
return count
|
||||
|
||||
|
||||
def _llm_wiki_page_files(wiki_path: Path) -> list[Path]:
|
||||
pages: list[Path] = []
|
||||
# Defense in depth: refuse forbidden system roots.
|
||||
try:
|
||||
if str(wiki_path.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
|
||||
return pages
|
||||
except Exception:
|
||||
return pages
|
||||
iterated = 0
|
||||
for dirname in _LLM_WIKI_PAGE_DIRS:
|
||||
section = wiki_path / dirname
|
||||
if not section.exists() or not section.is_dir():
|
||||
continue
|
||||
for item in section.rglob("*.md"):
|
||||
iterated += 1
|
||||
if iterated > _LLM_WIKI_MAX_FILES:
|
||||
return pages # bounded
|
||||
try:
|
||||
rel = item.relative_to(section)
|
||||
if item.is_file() and not any(part.startswith(".") for part in rel.parts):
|
||||
pages.append(item)
|
||||
except Exception:
|
||||
continue
|
||||
return pages
|
||||
|
||||
|
||||
def _build_llm_wiki_status() -> dict:
|
||||
"""Return private-safe LLM Wiki status metadata without reading page bodies."""
|
||||
try:
|
||||
wiki_path, path_source, path_configured = _llm_wiki_resolve_path()
|
||||
base = {
|
||||
"available": False,
|
||||
"enabled": False,
|
||||
"status": "missing",
|
||||
"entry_count": 0,
|
||||
"page_count": 0,
|
||||
"raw_source_count": 0,
|
||||
"last_updated": None,
|
||||
"last_writer": None,
|
||||
"path_configured": path_configured,
|
||||
"path_source": path_source,
|
||||
"toggle_available": False,
|
||||
"toggle_reason": "Hermes Agent exposes WIKI_PATH/wiki.path for location, but no stable on/off config flag is currently available.",
|
||||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||||
}
|
||||
if not wiki_path.exists():
|
||||
return base
|
||||
if not wiki_path.is_dir():
|
||||
base["status"] = "not_directory"
|
||||
return base
|
||||
|
||||
page_files = _llm_wiki_page_files(wiki_path)
|
||||
status_files = [p for p in (wiki_path / "SCHEMA.md", wiki_path / "index.md", wiki_path / "log.md") if p.exists() and p.is_file()]
|
||||
status_files.extend(page_files)
|
||||
latest = None
|
||||
for item in status_files:
|
||||
try:
|
||||
mtime = item.stat().st_mtime
|
||||
except Exception:
|
||||
continue
|
||||
latest = mtime if latest is None else max(latest, mtime)
|
||||
|
||||
base.update({
|
||||
"available": True,
|
||||
"enabled": True,
|
||||
"status": "ready" if page_files else "empty",
|
||||
"entry_count": len(page_files),
|
||||
"page_count": len(page_files),
|
||||
"raw_source_count": _llm_wiki_count_files(wiki_path / "raw"),
|
||||
"last_updated": _llm_wiki_safe_iso(latest),
|
||||
})
|
||||
return base
|
||||
except Exception as exc:
|
||||
return {
|
||||
"available": False,
|
||||
"enabled": False,
|
||||
"status": "error",
|
||||
"entry_count": 0,
|
||||
"page_count": 0,
|
||||
"raw_source_count": 0,
|
||||
"last_updated": None,
|
||||
"last_writer": None,
|
||||
"path_configured": False,
|
||||
"path_source": "unknown",
|
||||
"toggle_available": False,
|
||||
"toggle_reason": "Unable to inspect LLM Wiki status safely.",
|
||||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
|
||||
|
||||
def _handle_llm_wiki_status(handler, parsed) -> bool:
|
||||
j(handler, _build_llm_wiki_status())
|
||||
return True
|
||||
|
||||
|
||||
def _handle_insights(handler, parsed) -> bool:
|
||||
"""Return usage analytics from local WebUI session data."""
|
||||
import collections
|
||||
@@ -2143,7 +2471,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
handler.end_headers()
|
||||
return True
|
||||
|
||||
# ── Insights ──
|
||||
# ── Insights / knowledge status ──
|
||||
if parsed.path == "/api/insights":
|
||||
return _handle_insights(handler, parsed)
|
||||
|
||||
@@ -2151,6 +2479,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
from api.kanban_bridge import handle_kanban_get
|
||||
|
||||
return handle_kanban_get(handler, parsed)
|
||||
if parsed.path == "/api/wiki/status":
|
||||
return _handle_llm_wiki_status(handler, parsed)
|
||||
if parsed.path == "/api/logs":
|
||||
return _handle_logs(handler, parsed)
|
||||
|
||||
if parsed.path == "/health":
|
||||
return _handle_health(handler, parsed)
|
||||
@@ -2431,7 +2763,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/sessions":
|
||||
webui_sessions = all_sessions()
|
||||
settings = load_settings()
|
||||
if settings.get("show_cli_sessions"):
|
||||
show_cli_sessions = bool(settings.get("show_cli_sessions"))
|
||||
if show_cli_sessions:
|
||||
cli = get_cli_sessions()
|
||||
cli_by_id = {s["session_id"]: s for s in cli}
|
||||
for s in webui_sessions:
|
||||
@@ -2446,12 +2779,14 @@ def handle_get(handler, parsed) -> bool:
|
||||
for key in ("source_tag", "raw_source", "session_source", "source_label"):
|
||||
if not s.get(key) and meta.get(key):
|
||||
s[key] = meta[key]
|
||||
# Apply the same CLI visibility semantics to imported local copies so
|
||||
# low-value imported artifacts do not leak into the sidebar.
|
||||
webui_sessions = [s for s in webui_sessions if is_cli_session_row_visible(s)]
|
||||
webui_ids = {s["session_id"] for s in webui_sessions}
|
||||
from api.models import _hide_from_default_sidebar as _cron_hide
|
||||
deduped_cli = [s for s in cli
|
||||
if s["session_id"] not in webui_ids
|
||||
and not _cron_hide(s)]
|
||||
deduped_cli = [s for s in cli if s["session_id"] not in webui_ids and is_cli_session_row_visible(s) and not _cron_hide(s)]
|
||||
else:
|
||||
webui_sessions = [s for s in webui_sessions if not _is_cli_session_for_settings(s)]
|
||||
deduped_cli = []
|
||||
merged = webui_sessions + deduped_cli
|
||||
merged.sort(
|
||||
@@ -2483,6 +2818,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
if _profiles_match(s.get("profile"), active_profile)]
|
||||
other_profile_count = len(merged) - len(scoped)
|
||||
scoped = _keep_latest_messaging_session_per_source(scoped)
|
||||
if show_cli_sessions:
|
||||
scoped = _cap_recent_cli_sessions(scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
|
||||
safe_merged = []
|
||||
for s in scoped:
|
||||
item = dict(s)
|
||||
|
||||
BIN
docs/pr-media/1257/llm-wiki-status.png
Normal file
BIN
docs/pr-media/1257/llm-wiki-status.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/pr-media/1455/logs-tab-mvp.png
Normal file
BIN
docs/pr-media/1455/logs-tab-mvp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
123
static/i18n.js
123
static/i18n.js
@@ -532,6 +532,7 @@ const LOCALES = {
|
||||
tab_insights: 'Insights',
|
||||
tab_dashboard: 'Hermes Dashboard',
|
||||
dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: 'Settings',
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
@@ -552,6 +553,21 @@ const LOCALES = {
|
||||
new_skill: 'New skill',
|
||||
personal_memory: 'Personal memory',
|
||||
current_task_list: 'Current task list',
|
||||
// Logs
|
||||
logs_title: 'Logs',
|
||||
logs_file: 'File',
|
||||
logs_tail: 'Tail',
|
||||
logs_auto_refresh: 'Auto-refresh (5s)',
|
||||
logs_wrap: 'Wrap lines',
|
||||
logs_copy_all: 'Copy all',
|
||||
logs_empty: 'No log lines yet.',
|
||||
logs_loading: 'Loading logs…',
|
||||
logs_load_failed: 'Logs failed to load',
|
||||
logs_status_idle: 'Choose a log file to view recent lines.',
|
||||
logs_no_mtime: 'not written yet',
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.',
|
||||
logs_copied: 'Logs copied',
|
||||
|
||||
// Insights
|
||||
insights_title: 'Usage Analytics',
|
||||
insights_sessions: 'Sessions',
|
||||
@@ -1525,6 +1541,7 @@ const LOCALES = {
|
||||
tab_insights: 'インサイト',
|
||||
tab_dashboard: 'Hermes ダッシュボード',
|
||||
dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: '設定',
|
||||
new_conversation: '新しい会話',
|
||||
filter_conversations: '会話を絞り込み...',
|
||||
@@ -1545,6 +1562,21 @@ const LOCALES = {
|
||||
new_skill: '新規スキル',
|
||||
personal_memory: '個人メモリ',
|
||||
current_task_list: '現在のタスクリスト',
|
||||
// Logs
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
|
||||
// Insights
|
||||
insights_title: '使用状況分析',
|
||||
insights_sessions: 'セッション',
|
||||
@@ -2357,7 +2389,22 @@ const LOCALES = {
|
||||
tab_insights: 'Аналитика',
|
||||
tab_dashboard: 'Панель Hermes',
|
||||
dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: 'Настройки',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: 'Новая беседа',
|
||||
filter_conversations: 'Фильтр бесед...',
|
||||
session_time_unknown: 'Неизвестно',
|
||||
@@ -3285,7 +3332,22 @@ const LOCALES = {
|
||||
tab_insights: 'Analíticas',
|
||||
tab_dashboard: 'Panel de Hermes',
|
||||
dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: 'Ajustes',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
session_time_unknown: 'Desconocido',
|
||||
@@ -4201,7 +4263,22 @@ const LOCALES = {
|
||||
tab_insights: 'Statistiken',
|
||||
tab_dashboard: 'Hermes-Dashboard',
|
||||
dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: 'Einstellungen',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: 'Neuer Chat',
|
||||
filter_conversations: 'Chats filtern...',
|
||||
scheduled_jobs: 'Geplante Aufgaben',
|
||||
@@ -5140,7 +5217,22 @@ const LOCALES = {
|
||||
dashboard_loopback_warning: '仪表盘在服务器上仅限 loopback 访问。请从服务器本机浏览,或使用 --host 0.0.0.0 重启(不安全)。',
|
||||
tab_workspaces: '工作区',
|
||||
tab_profiles: '配置',
|
||||
tab_logs: '日志',
|
||||
tab_settings: '设置',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: '新建对话',
|
||||
filter_conversations: '筛选对话…',
|
||||
session_time_unknown: '未知',
|
||||
@@ -6025,6 +6117,7 @@ const LOCALES = {
|
||||
tab_insights: '統計',
|
||||
tab_dashboard: 'Hermes 儀表板',
|
||||
dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。',
|
||||
tab_logs: 'Logs',
|
||||
tab_workspaces: '\u5de5\u4f5c\u5340',
|
||||
new_conversation: '新對話',
|
||||
filter_conversations: '篩選對話',
|
||||
@@ -7094,7 +7187,22 @@ const LOCALES = {
|
||||
tab_insights: 'Estatísticas',
|
||||
tab_dashboard: 'Painel Hermes',
|
||||
dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: 'Configurações',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: 'Nova conversa',
|
||||
filter_conversations: 'Filtrar conversas...',
|
||||
session_time_unknown: 'Desconhecido',
|
||||
@@ -7986,7 +8094,22 @@ const LOCALES = {
|
||||
tab_insights: '통계',
|
||||
tab_dashboard: 'Hermes 대시보드',
|
||||
dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.',
|
||||
tab_logs: 'Logs',
|
||||
tab_settings: '설정',
|
||||
|
||||
logs_title: 'Logs', // TODO: translate
|
||||
logs_file: 'File', // TODO: translate
|
||||
logs_tail: 'Tail', // TODO: translate
|
||||
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
|
||||
logs_wrap: 'Wrap lines', // TODO: translate
|
||||
logs_copy_all: 'Copy all', // TODO: translate
|
||||
logs_empty: 'No log lines yet.', // TODO: translate
|
||||
logs_loading: 'Loading logs…', // TODO: translate
|
||||
logs_load_failed: 'Logs failed to load', // TODO: translate
|
||||
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
|
||||
logs_no_mtime: 'not written yet', // TODO: translate
|
||||
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
|
||||
logs_copied: 'Logs copied', // TODO: translate
|
||||
new_conversation: '새 대화',
|
||||
filter_conversations: '대화 필터…',
|
||||
session_time_unknown: 'Unknown',
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<button class="rail-btn nav-tab dashboard-link" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<button class="rail-btn nav-tab" data-panel="logs" onclick="switchPanel('logs')" title="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</nav>
|
||||
@@ -107,6 +108,7 @@
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<button class="nav-tab dashboard-link" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<button class="nav-tab" data-panel="logs" data-label="Logs" onclick="switchPanel('logs')" title="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
|
||||
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
|
||||
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</div>
|
||||
@@ -226,6 +228,33 @@
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:8px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Logs panel -->
|
||||
<div class="panel-view" id="panelLogs">
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_logs">Logs</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" id="logsRefreshBtn" onclick="loadLogs(true)" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-control-panel">
|
||||
<label class="logs-control-label" for="logsFile" data-i18n="logs_file">File</label>
|
||||
<select id="logsFile" onchange="loadLogs(true)">
|
||||
<option value="agent">agent</option>
|
||||
<option value="errors">errors</option>
|
||||
<option value="gateway">gateway</option>
|
||||
</select>
|
||||
<label class="logs-control-label" for="logsTail" data-i18n="logs_tail">Tail</label>
|
||||
<select id="logsTail" onchange="loadLogs(true)">
|
||||
<option value="100">100</option>
|
||||
<option value="200" selected>200</option>
|
||||
<option value="500">500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
<label class="logs-check-row"><input id="logsAutoRefresh" type="checkbox" checked onchange="_syncLogsAutoRefresh()"><span data-i18n="logs_auto_refresh">Auto-refresh (5s)</span></label>
|
||||
<label class="logs-check-row"><input id="logsWrap" type="checkbox" onchange="_syncLogsWrap()"><span data-i18n="logs_wrap">Wrap lines</span></label>
|
||||
<button type="button" class="logs-copy" id="logsCopyAll" onclick="copyLogsAll()" data-i18n="logs_copy_all">Copy all</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings panel (menu list; actual panes render in .main) -->
|
||||
<div class="panel-view" id="panelSettings">
|
||||
<div class="panel-head">
|
||||
@@ -716,7 +745,25 @@
|
||||
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
|
||||
</div>
|
||||
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
|
||||
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
|
||||
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
|
||||
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainLogs" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div>
|
||||
<div class="main-view-title" data-i18n="logs_title">Logs</div>
|
||||
<div class="logs-status" id="logsStatus" data-i18n="logs_status_idle">Choose a log file to view recent lines.</div>
|
||||
</div>
|
||||
<div class="main-view-actions">
|
||||
<button type="button" class="logs-copy compact" onclick="copyLogsAll()" data-i18n="logs_copy_all">Copy all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-view-body logs-main-body">
|
||||
<div class="main-view-content logs-content">
|
||||
<div class="logs-output" id="logsOutput"><div class="logs-empty" data-i18n="logs_empty">No log lines yet.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainSettings" class="main-view">
|
||||
|
||||
184
static/panels.js
184
static/panels.js
@@ -29,12 +29,14 @@ let _currentProfileDetail = null; // full profile object
|
||||
let _profileMode = 'empty'; // 'empty' | 'read' | 'create'
|
||||
let _profilePreFormDetail = null;
|
||||
let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes
|
||||
let _logsAutoRefreshTimer = null;
|
||||
let _lastLogsLines = [];
|
||||
|
||||
// Map of panel names → i18n keys for the app titlebar label.
|
||||
const APP_TITLEBAR_KEYS = {
|
||||
chat: 'tab_chat', tasks: 'tab_tasks', skills: 'tab_skills',
|
||||
memory: 'tab_memory', workspaces: 'tab_workspaces',
|
||||
profiles: 'tab_profiles', todos: 'tab_todos', settings: 'tab_settings',
|
||||
profiles: 'tab_profiles', todos: 'tab_todos', insights: 'tab_insights', logs: 'tab_logs', settings: 'tab_settings',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -198,7 +200,7 @@ async function switchPanel(name, opts = {}) {
|
||||
// showing-<name> class on <main>; no class means chat (the default).
|
||||
const mainEl = document.querySelector('main.main');
|
||||
if (mainEl) {
|
||||
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights'].forEach(p => {
|
||||
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs'].forEach(p => {
|
||||
mainEl.classList.toggle('showing-' + p, nextPanel === p);
|
||||
});
|
||||
}
|
||||
@@ -211,6 +213,8 @@ async function switchPanel(name, opts = {}) {
|
||||
if (nextPanel === 'profiles') await loadProfilesPanel();
|
||||
if (nextPanel === 'todos') loadTodos();
|
||||
if (nextPanel === 'insights') await loadInsights();
|
||||
if (nextPanel === 'logs') await loadLogs();
|
||||
_syncLogsAutoRefresh();
|
||||
if (nextPanel === 'settings') {
|
||||
switchSettingsSection(_currentSettingsSection);
|
||||
loadSettingsPanel();
|
||||
@@ -1984,6 +1988,120 @@ async function archiveKanbanBoard(){
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Logs panel ──
|
||||
function _selectedLogsFile() {
|
||||
const el = $('logsFile');
|
||||
const value = (el && el.value) || 'agent';
|
||||
return ['agent','errors','gateway'].includes(value) ? value : 'agent';
|
||||
}
|
||||
|
||||
function _selectedLogsTail() {
|
||||
const el = $('logsTail');
|
||||
const value = Number((el && el.value) || 200);
|
||||
return [100,200,500,1000].includes(value) ? value : 200;
|
||||
}
|
||||
|
||||
function _logLineSeverityClass(line) {
|
||||
const text = String(line || '').toUpperCase();
|
||||
if (/\b(WARNING|WARN)\b/.test(text)) return 'log-line-warning';
|
||||
if (/\b(DEBUG)\b/.test(text)) return 'log-line-debug';
|
||||
if (/\b(INFO)\b/.test(text)) return 'log-line-info';
|
||||
if (/\b(ERROR|CRITICAL|TRACEBACK)\b/.test(text)) return 'log-line-error';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _syncLogsWrap() {
|
||||
const out = $('logsOutput');
|
||||
const wrap = $('logsWrap');
|
||||
if (out && wrap) out.classList.toggle('wrap', !!wrap.checked);
|
||||
}
|
||||
|
||||
async function loadLogs(animate) {
|
||||
const box = $('logsOutput');
|
||||
const status = $('logsStatus');
|
||||
const refreshBtn = $('logsRefreshBtn');
|
||||
if (!box) return;
|
||||
if (animate && refreshBtn) {
|
||||
refreshBtn.style.opacity = '0.5';
|
||||
refreshBtn.disabled = true;
|
||||
}
|
||||
const file = _selectedLogsFile();
|
||||
const tail = _selectedLogsTail();
|
||||
try {
|
||||
if (status) status.textContent = t('logs_loading');
|
||||
const data = await api('/api/logs?file=' + encodeURIComponent(file) + '&tail=' + encodeURIComponent(tail));
|
||||
_renderLogs(data);
|
||||
} catch(e) {
|
||||
_lastLogsLines = [];
|
||||
box.innerHTML = `<div class="logs-empty">${esc(t('error_prefix') + e.message)}</div>`;
|
||||
if (status) status.textContent = t('logs_load_failed');
|
||||
} finally {
|
||||
if (animate && refreshBtn) {
|
||||
refreshBtn.style.opacity = '';
|
||||
refreshBtn.disabled = false;
|
||||
}
|
||||
_syncLogsAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLogs(data) {
|
||||
const box = $('logsOutput');
|
||||
const status = $('logsStatus');
|
||||
if (!box) return;
|
||||
const lines = Array.isArray(data && data.lines) ? data.lines : [];
|
||||
_lastLogsLines = lines.slice();
|
||||
const hint = data && data.hint ? `<div class="logs-hint">${esc(data.hint)}</div>` : '';
|
||||
const truncated = data && data.truncated ? `<div class="logs-hint warn">${esc(t('logs_truncated_hint'))}</div>` : '';
|
||||
if (!lines.length) {
|
||||
box.innerHTML = `${hint}${truncated}<div class="logs-empty">${esc(t('logs_empty'))}</div>`;
|
||||
} else {
|
||||
box.innerHTML = `${hint}${truncated}` + lines.map(line => {
|
||||
const cls = _logLineSeverityClass(line);
|
||||
return `<div class="log-line ${cls}">${esc(line)}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
_syncLogsWrap();
|
||||
if (status) {
|
||||
const bytes = data && Number(data.total_bytes || 0);
|
||||
const when = data && data.mtime ? new Date(data.mtime * 1000).toLocaleString() : t('logs_no_mtime');
|
||||
status.textContent = `${lines.length} / ${data.tail || _selectedLogsTail()} lines · ${bytes.toLocaleString()} bytes · ${when}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _startLogsAutoRefresh() {
|
||||
if (_logsAutoRefreshTimer) return;
|
||||
_logsAutoRefreshTimer = setInterval(() => {
|
||||
if (_currentPanel !== 'logs') { _stopLogsAutoRefresh(); return; }
|
||||
const toggle = $('logsAutoRefresh');
|
||||
if (toggle && !toggle.checked) return;
|
||||
loadLogs(false);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function _stopLogsAutoRefresh() {
|
||||
if (_logsAutoRefreshTimer) {
|
||||
clearInterval(_logsAutoRefreshTimer);
|
||||
_logsAutoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _syncLogsAutoRefresh() {
|
||||
const toggle = $('logsAutoRefresh');
|
||||
if (_currentPanel === 'logs' && (!toggle || toggle.checked)) _startLogsAutoRefresh();
|
||||
else _stopLogsAutoRefresh();
|
||||
}
|
||||
|
||||
async function copyLogsAll() {
|
||||
const text = _lastLogsLines.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t('logs_copied'));
|
||||
} catch(e) {
|
||||
showToast(t('copy_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insights panel ──
|
||||
async function loadInsights(animate) {
|
||||
const box = $('insightsContent');
|
||||
@@ -1995,8 +2113,11 @@ async function loadInsights(animate) {
|
||||
}
|
||||
const period = ($('insightsPeriod') || {}).value || '30';
|
||||
try {
|
||||
const data = await api(`/api/insights?days=${period}`);
|
||||
_renderInsights(data, box);
|
||||
const [data, wikiStatus] = await Promise.all([
|
||||
api(`/api/insights?days=${period}`),
|
||||
api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})),
|
||||
]);
|
||||
_renderInsights(data, box, wikiStatus);
|
||||
} catch(e) {
|
||||
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
|
||||
} finally {
|
||||
@@ -2007,7 +2128,59 @@ async function loadInsights(animate) {
|
||||
}
|
||||
}
|
||||
|
||||
function _renderInsights(d, box) {
|
||||
function _formatLlmWikiTimestamp(value) {
|
||||
if (!value) return 'Never';
|
||||
try { return new Date(value).toLocaleString(); }
|
||||
catch (_) { return String(value); }
|
||||
}
|
||||
|
||||
function _renderLlmWikiStatus(d) {
|
||||
const status = d || {status:'error'};
|
||||
const isReady = status.available && status.status === 'ready';
|
||||
const isEmpty = status.available && status.status === 'empty';
|
||||
const isError = status.status === 'error';
|
||||
const badgeClass = isReady ? 'ok' : isError ? 'err' : isEmpty ? 'warn' : 'muted';
|
||||
const badgeText = isReady ? 'Available' : isError ? 'Error' : isEmpty ? 'Empty' : 'Unavailable';
|
||||
const rawDocsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki';
|
||||
// Guard against unsafe URL schemes (e.g. js: / data:) if docs_url ever
|
||||
// becomes config-driven. esc() HTML-escapes but doesn't validate URL scheme.
|
||||
const docsUrl = /^https?:\/\//i.test(rawDocsUrl) ? rawDocsUrl : '#';
|
||||
const toggleNote = status.toggle_available
|
||||
? 'Toggle available from configured Hermes Agent setting.'
|
||||
: (status.toggle_reason || 'No stable LLM Wiki on/off config flag was detected, so this panel is read-only.');
|
||||
const statusNote = isReady
|
||||
? 'LLM Wiki is configured and page metadata is visible without exposing wiki content.'
|
||||
: isEmpty
|
||||
? 'LLM Wiki exists but has no entity, concept, comparison, or query pages yet.'
|
||||
: isError
|
||||
? `Unable to inspect LLM Wiki status${status.error ? ': ' + status.error : ''}.`
|
||||
: 'No LLM Wiki directory was found. Set WIKI_PATH or skills.config.wiki.path to enable status visibility.';
|
||||
return `
|
||||
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
|
||||
<div class="wiki-status-head">
|
||||
<div>
|
||||
<div class="insights-card-title">LLM Wiki</div>
|
||||
<div class="wiki-status-sub">Knowledge-base observability</div>
|
||||
</div>
|
||||
<span class="wiki-status-badge ${badgeClass}">${esc(badgeText)}</span>
|
||||
</div>
|
||||
<div class="wiki-status-note">${esc(statusNote)}</div>
|
||||
<div class="wiki-status-grid">
|
||||
<div><span>Enabled</span><strong>${status.enabled ? 'Yes' : 'No'}</strong></div>
|
||||
<div><span>Entries</span><strong>${Number(status.entry_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>Pages</span><strong>${Number(status.page_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>raw/ files</span><strong>${Number(status.raw_source_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>Last updated</span><strong>${esc(_formatLlmWikiTimestamp(status.last_updated))}</strong></div>
|
||||
<div><span>Last writer</span><strong>${esc(status.last_writer || 'Not available')}</strong></div>
|
||||
</div>
|
||||
<div class="wiki-status-footer">
|
||||
<span>${esc(toggleNote)}</span>
|
||||
<a href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderInsights(d, box, wikiStatus) {
|
||||
const fmtNum = n => Number(n || 0).toLocaleString();
|
||||
const fmtCost = c => {
|
||||
const value = Number(c || 0);
|
||||
@@ -2106,6 +2279,7 @@ function _renderInsights(d, box) {
|
||||
</div>`;
|
||||
|
||||
box.innerHTML = `
|
||||
${_renderLlmWikiStatus(wikiStatus)}
|
||||
<div class="insights-grid">
|
||||
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
|
||||
</div>
|
||||
|
||||
@@ -584,6 +584,24 @@ function _sourceKeyForSession(session) {
|
||||
return (session && (session.raw_source || session.source_tag || session.source || '') || '').toLowerCase();
|
||||
}
|
||||
|
||||
function _isCliSession(session) {
|
||||
if (!session) return false;
|
||||
// session_source is set by upstream normalization for CLI sessions as 'cli'
|
||||
if (session.session_source === 'cli') return true;
|
||||
// Legacy payloads often use raw/source tags to convey the source.
|
||||
const raw = (
|
||||
session.raw_source
|
||||
|| session.source_tag
|
||||
|| session.source
|
||||
|| session.source_label
|
||||
|| ''
|
||||
).toLowerCase();
|
||||
if (raw === 'cli') return true;
|
||||
// If messaging-like, don't classify as legacy CLI even when is_cli_session is true.
|
||||
if (_isMessagingSession(session)) return false;
|
||||
return session.is_cli_session === true;
|
||||
}
|
||||
|
||||
function _normalizeMessageForCliImportComparison(message) {
|
||||
if (!message || typeof message !== 'object') return message;
|
||||
const clone = { ...message };
|
||||
@@ -1281,6 +1299,8 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
}
|
||||
closeSessionActionMenu();
|
||||
const isMessagingSession = _isMessagingSession(session);
|
||||
const isCliSession = _isCliSession(session);
|
||||
const isExternalSession = isMessagingSession || isCliSession;
|
||||
const menu=document.createElement('div');
|
||||
menu.className='session-action-menu open';
|
||||
menu.appendChild(_buildSessionAction(
|
||||
@@ -1323,7 +1343,7 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
if(!isMessagingSession){
|
||||
if(!isExternalSession){
|
||||
_appendSessionDuplicateAction(menu, session);
|
||||
}
|
||||
if(session.active_stream_id){
|
||||
@@ -1338,7 +1358,7 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
}
|
||||
));
|
||||
}
|
||||
if(!isMessagingSession){
|
||||
if(!isExternalSession){
|
||||
menu.appendChild(_buildSessionAction(
|
||||
t('session_delete'),
|
||||
t('session_delete_desc'),
|
||||
@@ -1916,6 +1936,16 @@ function _sessionVirtualSpacer(height, where){
|
||||
|
||||
function _scheduleSessionVirtualizedRender(){
|
||||
if(_renamingSid||_sessionVirtualScrollRaf) return;
|
||||
// Skip the re-render if the list is below the virtualization threshold —
|
||||
// there's no virtual window to recompute, and re-rendering would just
|
||||
// rebuild the whole DOM on every scroll tick. Without this guard, the
|
||||
// unconditional scroll listener (attached for any list) caused
|
||||
// user-facing scroll jumps on small lists. (#1669 follow-up)
|
||||
const list=_sessionVirtualScrollList;
|
||||
if(list){
|
||||
const total=Number(list.dataset.sessionVirtualTotal||0);
|
||||
if(total>0&&total<=SESSION_VIRTUAL_THRESHOLD_ROWS) return;
|
||||
}
|
||||
_sessionVirtualScrollRaf=requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();});
|
||||
}
|
||||
|
||||
@@ -2182,7 +2212,13 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
if(virtualAnchorScrollTop!==null){
|
||||
list.scrollTop=virtualAnchorScrollTop;
|
||||
}else if(virtualWindow.virtualized){
|
||||
}else if(listScrollTopBeforeRender>0){
|
||||
// Always restore the user's scroll position after re-render, regardless
|
||||
// of whether the virtualization window applies. Lists below the
|
||||
// virtualization threshold (≤80 rows) still have their DOM rebuilt by
|
||||
// every renderSessionListFromCache() call, and without this restore the
|
||||
// scrollTop drops to 0 — producing a "scroll keeps jumping back" feel
|
||||
// when the list scrolls naturally. Fixed for #1669 follow-up.
|
||||
list.scrollTop=listScrollTopBeforeRender;
|
||||
}
|
||||
// Select mode toggle button (only when NOT in select mode)
|
||||
|
||||
@@ -2156,8 +2156,9 @@ main.main > #mainTasks,
|
||||
main.main > #mainKanban,
|
||||
main.main > #mainWorkspaces,
|
||||
main.main > #mainProfiles,
|
||||
main.main > #mainInsights{display:none;}
|
||||
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
|
||||
main.main > #mainInsights,
|
||||
main.main > #mainLogs{display:none;}
|
||||
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs) > #mainChat{display:flex;}
|
||||
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
|
||||
main.main.showing-skills > #mainSkills{display:flex;}
|
||||
main.main.showing-memory > #mainMemory{display:flex;}
|
||||
@@ -2165,6 +2166,7 @@ main.main.showing-tasks > #mainTasks{display:flex;}
|
||||
main.main.showing-kanban > #mainKanban{display:flex;}
|
||||
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
|
||||
main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
main.main.showing-logs > #mainLogs{display:flex;}
|
||||
#mainSettings{overflow-y:auto;}
|
||||
|
||||
/* Sidebar menu (lives in the left sidebar under the cog panel) */
|
||||
@@ -3142,6 +3144,21 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
|
||||
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
|
||||
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:16px;}
|
||||
.wiki-status-card{margin-bottom:16px;}
|
||||
.wiki-status-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;}
|
||||
.wiki-status-sub{font-size:11px;color:var(--muted);margin-top:-4px;}
|
||||
.wiki-status-badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);}
|
||||
.wiki-status-badge.ok{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);}
|
||||
.wiki-status-badge.warn{color:#e8a030;background:rgba(232,160,48,.12);border-color:rgba(232,160,48,.28);}
|
||||
.wiki-status-badge.err{color:var(--error,#e05);background:color-mix(in srgb,var(--error,#e05) 10%,transparent);border-color:color-mix(in srgb,var(--error,#e05) 30%,transparent);}
|
||||
.wiki-status-note{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:12px;}
|
||||
.wiki-status-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-bottom:12px;}
|
||||
.wiki-status-grid div{display:flex;flex-direction:column;gap:3px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;}
|
||||
.wiki-status-grid span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.wiki-status-grid strong{font-size:13px;color:var(--text);font-weight:650;overflow-wrap:anywhere;}
|
||||
.wiki-status-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:10px;}
|
||||
.wiki-status-footer a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap;}
|
||||
.wiki-status-footer a:hover{text-decoration:underline;}
|
||||
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
|
||||
.insights-table{width:100%;font-size:12px;}
|
||||
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
|
||||
@@ -3450,3 +3467,27 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-modal{padding:16px 16px 14px;border-radius:14px;}
|
||||
.kanban-modal-row-inline{flex-direction:column;gap:0;}
|
||||
}
|
||||
|
||||
/* ── Logs panel (#1455) ───────────────────────────────────────────────────── */
|
||||
main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.logs-control-panel{display:flex;flex-direction:column;gap:8px;padding:12px;overflow-y:auto;}
|
||||
.logs-control-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);}
|
||||
.logs-control-panel select{width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:7px 9px;font-size:12px;}
|
||||
.logs-check-row{display:flex;align-items:center;gap:8px;color:var(--text);font-size:12px;line-height:1.4;}
|
||||
.logs-check-row input{accent-color:var(--accent);}
|
||||
.logs-copy{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:8px;padding:7px 10px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
|
||||
.logs-copy:hover{background:var(--hover-bg);border-color:var(--border2);}
|
||||
.logs-copy.compact{padding:6px 10px;white-space:nowrap;}
|
||||
.logs-status{font-size:12px;color:var(--muted);margin-top:3px;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.logs-main-body{padding:18px 24px;}
|
||||
.logs-content{max-width:1200px;}
|
||||
.logs-output{min-height:320px;max-height:calc(100vh - 170px);overflow:auto;background:var(--code-bg);border:1px solid var(--border);border-radius:12px;padding:12px 0;font-family:'SF Mono','Fira Code',ui-monospace,monospace;font-size:12px;line-height:1.55;color:var(--pre-text);white-space:pre;}
|
||||
.logs-output.wrap{white-space:pre-wrap;overflow-wrap:anywhere;}
|
||||
.log-line{padding:0 14px;min-height:1.55em;border-left:3px solid transparent;}
|
||||
.log-line:hover{background:rgba(255,255,255,.04);}
|
||||
.log-line-error{color:var(--error,#ef4444);border-left-color:var(--error,#ef4444);background:color-mix(in srgb,var(--error,#ef4444) 8%,transparent);}
|
||||
.log-line-warning{color:#f59e0b;border-left-color:#f59e0b;background:rgba(245,158,11,.08);}
|
||||
.log-line-info{color:var(--pre-text);}
|
||||
.log-line-debug{color:var(--muted);opacity:.75;}
|
||||
.logs-empty,.logs-hint{margin:8px 14px;padding:12px;border:1px solid var(--border);border-radius:8px;color:var(--muted);background:var(--surface);white-space:normal;font-family:var(--font-ui,system-ui,sans-serif);font-size:12px;}
|
||||
.logs-hint.warn{color:#f59e0b;border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.08);}
|
||||
|
||||
@@ -52,3 +52,27 @@ class TestSidebarCancelAction:
|
||||
)
|
||||
assert "hideClarifyCard(true" in body
|
||||
assert "hideApprovalCard(true" in body
|
||||
|
||||
def test_cli_session_helper_identifies_cli_origin(self):
|
||||
"""CLI sessions should be treated as external-only for destructive action gating."""
|
||||
body = _function_body(SESSIONS_JS, "_isCliSession", 900)
|
||||
assert "function _isCliSession(session) {" in body
|
||||
assert "session.session_source === 'cli'" in body
|
||||
assert "session.raw_source" in body
|
||||
assert "session.source_tag" in body
|
||||
assert "session.source" in body
|
||||
assert "session.source_label" in body
|
||||
assert "if (_isMessagingSession(session)) return false;" in body
|
||||
assert "return session.is_cli_session === true;" in body
|
||||
|
||||
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
|
||||
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 3600)
|
||||
assert "const isCliSession = _isCliSession(session);" in body
|
||||
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
|
||||
assert "if(!isExternalSession)" in body
|
||||
# duplicate/delete should both be gated by the same external-session check
|
||||
first = body.find("_appendSessionDuplicateAction")
|
||||
second = body.find("t('session_delete')")
|
||||
assert first > 0 and second > 0, "menu actions should still include duplicate/delete nodes"
|
||||
assert first < second, "duplicate action should render before delete action"
|
||||
|
||||
@@ -142,6 +142,16 @@ def test_non_cron_sessions_unaffected(fake_hermes_home):
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_xx", None, "cli"),
|
||||
])
|
||||
# PR #1587 hides one-off default-titled CLI rows. Keep this fixture visible
|
||||
# so the test remains focused on the cron-name guard rather than sidebar
|
||||
# filtering.
|
||||
conn = sqlite3.connect(str(fake_hermes_home / "state.db"))
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
|
||||
("cron_cd65df6fc1a8_xx", 1700000002.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
|
||||
@@ -508,6 +508,51 @@ def test_compression_chain_with_all_empty_segments_is_hidden():
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_default_title_cli_compression_chain_is_kept_by_lineage():
|
||||
"""Default-titled CLI compression chains are meaningful even with a short tip."""
|
||||
conn = _ensure_state_db()
|
||||
ids_to_remove = ('cli_default_compress_root_001', 'cli_default_compress_tip_001')
|
||||
t0 = time.time() - 430
|
||||
try:
|
||||
_insert_agent_session_row(
|
||||
conn,
|
||||
'cli_default_compress_root_001',
|
||||
source='cli',
|
||||
title='Cli Session',
|
||||
started_at=t0,
|
||||
ended_at=t0 + 100,
|
||||
end_reason='compression',
|
||||
messages=1,
|
||||
)
|
||||
_insert_agent_session_row(
|
||||
conn,
|
||||
'cli_default_compress_tip_001',
|
||||
source='cli',
|
||||
title='Cli Session',
|
||||
started_at=t0 + 101,
|
||||
parent_session_id='cli_default_compress_root_001',
|
||||
messages=1,
|
||||
)
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
||||
|
||||
assert 'cli_default_compress_tip_001' in ids
|
||||
assert 'cli_default_compress_root_001' not in ids
|
||||
tip = next(s for s in data.get('sessions', []) if s.get('session_id') == 'cli_default_compress_tip_001')
|
||||
assert tip.get('_compression_segment_count') == 2
|
||||
assert tip.get('_lineage_root_id') == 'cli_default_compress_root_001'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, *ids_to_remove)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_non_compression_child_is_not_collapsed_into_parent():
|
||||
"""Parent/child relationships that are not compression continuations stay flat."""
|
||||
conn = _ensure_state_db()
|
||||
|
||||
100
tests/test_issue1257_llm_wiki_status.py
Normal file
100
tests/test_issue1257_llm_wiki_status.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _write(path: Path, text: str = "# Synthetic\n") -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_llm_wiki_status_reads_synthetic_fixture_without_exposing_content(tmp_path, monkeypatch):
|
||||
"""The wiki status API should summarize counts/mtime without leaking page text."""
|
||||
import api.routes as routes
|
||||
|
||||
wiki = tmp_path / "wiki"
|
||||
_write(wiki / "SCHEMA.md", "# Schema\n")
|
||||
_write(wiki / "index.md", "# Index\n")
|
||||
_write(wiki / "log.md", "# Log\n## [2026-05-04] update | Secret project name\n- Details stay private\n")
|
||||
_write(
|
||||
wiki / "entities" / "private-agent.md",
|
||||
"---\ntitle: Private Agent\nupdated: 2026-05-04\n---\nSensitive body text must not ship.\n",
|
||||
)
|
||||
_write(wiki / "concepts" / "safe-summary.md", "---\ntitle: Safe Summary\n---\nMore private text\n")
|
||||
_write(wiki / "raw" / "articles" / "source.md", "Raw source body should not count as wiki page\n")
|
||||
|
||||
monkeypatch.setenv("WIKI_PATH", str(wiki))
|
||||
|
||||
status = routes._build_llm_wiki_status()
|
||||
|
||||
assert status["available"] is True
|
||||
assert status["enabled"] is True
|
||||
assert status["entry_count"] == 2
|
||||
assert status["page_count"] == 2
|
||||
assert status["raw_source_count"] == 1
|
||||
assert status["last_updated"] is not None
|
||||
assert status["last_writer"] is None
|
||||
assert status["toggle_available"] is False
|
||||
assert status["docs_url"].endswith("/research-llm-wiki")
|
||||
serialized = repr(status)
|
||||
assert "Sensitive body text" not in serialized
|
||||
assert "Secret project name" not in serialized
|
||||
assert str(wiki) not in serialized
|
||||
|
||||
|
||||
def test_llm_wiki_status_reports_unavailable_when_path_missing(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
missing = tmp_path / "does-not-exist"
|
||||
monkeypatch.setenv("WIKI_PATH", str(missing))
|
||||
|
||||
status = routes._build_llm_wiki_status()
|
||||
|
||||
assert status["available"] is False
|
||||
assert status["enabled"] is False
|
||||
assert status["entry_count"] == 0
|
||||
assert status["page_count"] == 0
|
||||
assert status["raw_source_count"] == 0
|
||||
assert status["last_updated"] is None
|
||||
assert status["status"] == "missing"
|
||||
|
||||
|
||||
def test_api_wiki_status_route_is_registered(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
wiki = tmp_path / "wiki"
|
||||
_write(wiki / "entities" / "one.md")
|
||||
monkeypatch.setenv("WIKI_PATH", str(wiki))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, payload, status=200, extra_headers=None):
|
||||
captured["status"] = status
|
||||
captured["payload"] = payload
|
||||
|
||||
with patch("api.routes.j", side_effect=fake_j):
|
||||
handled = routes.handle_get(SimpleNamespace(), urlparse("/api/wiki/status"))
|
||||
|
||||
assert handled is True
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["entry_count"] == 1
|
||||
|
||||
|
||||
def test_insights_panel_fetches_and_renders_llm_wiki_status_card():
|
||||
panels_src = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
index_src = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
style_src = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "api('/api/wiki/status')" in panels_src
|
||||
assert "function _renderLlmWikiStatus" in panels_src
|
||||
assert "llmWikiStatusCard" in index_src
|
||||
assert "wiki-status-card" in style_src
|
||||
assert "raw/" in panels_src
|
||||
assert "recent_entries" not in panels_src
|
||||
87
tests/test_issue1669_sidebar_scroll_jump_fix.py
Normal file
87
tests/test_issue1669_sidebar_scroll_jump_fix.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Regression test for #1669 follow-up — sidebar scroll jump fix.
|
||||
|
||||
The original PR #1669 added DOM virtualization to renderSessionListFromCache,
|
||||
which:
|
||||
|
||||
1. Attached an unconditional scroll listener to the session list
|
||||
2. The scroll listener triggers renderSessionListFromCache() on every rAF
|
||||
3. The render rebuilds the list DOM via list.innerHTML='' / appendChild loop
|
||||
4. After the rebuild, scrollTop was only restored when virtualWindow.virtualized
|
||||
was true (i.e. total > 80 rows)
|
||||
5. For lists ≤ 80 rows, the scrollTop reset to 0 on every scroll event,
|
||||
producing a "scroll keeps jumping back" feel.
|
||||
|
||||
This test pins:
|
||||
- The non-virtualized branch always restores scrollTop after a rebuild
|
||||
- The scroll handler short-circuits when total <= threshold (prevents the
|
||||
rebuild churn entirely on small lists)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
SESSIONS_JS = Path(__file__).parent.parent / "static" / "sessions.js"
|
||||
|
||||
|
||||
def _read_source():
|
||||
return SESSIONS_JS.read_text()
|
||||
|
||||
|
||||
def test_render_restores_scroll_top_for_non_virtualized_lists():
|
||||
"""The bug: virtualWindow.virtualized=false skipped the scrollTop restore.
|
||||
|
||||
The fix: restore scrollTop whenever listScrollTopBeforeRender > 0,
|
||||
regardless of virtualized flag. Otherwise small lists (≤80 rows) reset
|
||||
to scrollTop=0 on every render.
|
||||
"""
|
||||
src = _read_source()
|
||||
# The new branch must include listScrollTopBeforeRender>0 as the guard
|
||||
# rather than virtualWindow.virtualized
|
||||
assert "}else if(listScrollTopBeforeRender>0){" in src, (
|
||||
"Expected the scrollTop-restore guard to use listScrollTopBeforeRender>0, "
|
||||
"not virtualWindow.virtualized — without this fix, small lists drop "
|
||||
"scrollTop to 0 on every scroll event."
|
||||
)
|
||||
|
||||
|
||||
def test_scroll_handler_short_circuits_below_virtualization_threshold():
|
||||
"""The bug: the rAF re-render fired on every scroll event regardless of
|
||||
whether virtualization was actually needed. For ≤80-row lists this caused
|
||||
full DOM rebuild on every scroll tick.
|
||||
|
||||
The fix: _scheduleSessionVirtualizedRender skips the rebuild when
|
||||
total <= SESSION_VIRTUAL_THRESHOLD_ROWS — there's no virtual window to
|
||||
recompute on small lists, and the rebuild was wasteful (and bug-prone).
|
||||
"""
|
||||
src = _read_source()
|
||||
# Locate the function body
|
||||
start = src.find("function _scheduleSessionVirtualizedRender()")
|
||||
end = src.find("function _ensureSessionVirtualScrollHandler", start)
|
||||
body = src[start:end]
|
||||
# The fix introduces an early-return when total <= SESSION_VIRTUAL_THRESHOLD_ROWS
|
||||
assert "SESSION_VIRTUAL_THRESHOLD_ROWS" in body, (
|
||||
"Expected _scheduleSessionVirtualizedRender to read the threshold; "
|
||||
"without this guard, the rAF re-render fires on every scroll event "
|
||||
"even when there's nothing to virtualize."
|
||||
)
|
||||
assert "total<=SESSION_VIRTUAL_THRESHOLD_ROWS" in body or "total <= SESSION_VIRTUAL_THRESHOLD_ROWS" in body, (
|
||||
"Expected explicit total<=THRESHOLD comparison to short-circuit the re-render."
|
||||
)
|
||||
# The early return must be BEFORE the rAF schedule (else it's dead code)
|
||||
early_return_idx = body.find("return")
|
||||
raf_idx = body.find("requestAnimationFrame")
|
||||
assert early_return_idx > 0 and early_return_idx < raf_idx, (
|
||||
"The total<=THRESHOLD short-circuit must return BEFORE scheduling the rAF."
|
||||
)
|
||||
|
||||
|
||||
def test_virtualization_still_active_for_large_lists():
|
||||
"""Regression: ensure the threshold + virtualWindow logic is still in place
|
||||
for large lists. The fix must not break the original virtualization path.
|
||||
"""
|
||||
src = _read_source()
|
||||
assert "SESSION_VIRTUAL_THRESHOLD_ROWS = 80" in src, (
|
||||
"Threshold constant must remain at 80 rows."
|
||||
)
|
||||
# _sessionVirtualWindow function still defined
|
||||
assert "function _sessionVirtualWindow" in src
|
||||
# virtualWindow.virtualized branch still drives spacer rendering
|
||||
assert "virtualWindow.virtualized" in src
|
||||
118
tests/test_logs_endpoint.py
Normal file
118
tests/test_logs_endpoint.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from tests._pytest_port import BASE, TEST_STATE_DIR
|
||||
|
||||
|
||||
def _get_logs(file="agent", tail=200):
|
||||
url = f"{BASE}/api/logs?file={urllib.parse.quote(str(file))}&tail={urllib.parse.quote(str(tail))}"
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def _get_logs_error(file="agent", tail=200):
|
||||
url = f"{BASE}/api/logs?file={urllib.parse.quote(str(file))}&tail={urllib.parse.quote(str(tail))}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
def test_logs_endpoint_tails_whitelisted_synthetic_agent_log():
|
||||
logs_dir = TEST_STATE_DIR / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(logs_dir / "agent.log").write_text(
|
||||
"\n".join(
|
||||
[f"2026-05-04 INFO synthetic-log-marker line {i}" for i in range(105)]
|
||||
+ ["2026-05-04 ERROR synthetic-log-marker failed safely"]
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data, status = _get_logs("agent", 100)
|
||||
|
||||
assert status == 200
|
||||
assert data["file"] == "agent"
|
||||
assert data["tail"] == 100
|
||||
assert len(data["lines"]) == 100
|
||||
assert data["lines"][0] == "2026-05-04 INFO synthetic-log-marker line 6"
|
||||
assert data["lines"][-1] == "2026-05-04 ERROR synthetic-log-marker failed safely"
|
||||
assert data["truncated"] is False
|
||||
assert data["total_bytes"] > 0
|
||||
assert data["mtime"] > 0
|
||||
assert data.get("hint") == ""
|
||||
|
||||
|
||||
def test_logs_endpoint_rejects_path_traversal_and_unknown_files():
|
||||
for bad_file in ("../../etc/passwd", "agent.log", "private", "/tmp/agent"):
|
||||
data, status = _get_logs_error(bad_file, 200)
|
||||
assert status == 400
|
||||
assert "error" in data
|
||||
|
||||
|
||||
def test_logs_endpoint_missing_file_returns_empty_lines_with_safe_hint():
|
||||
missing = TEST_STATE_DIR / "logs" / "gateway.log"
|
||||
if missing.exists():
|
||||
missing.unlink()
|
||||
|
||||
data, status = _get_logs("gateway", 200)
|
||||
|
||||
assert status == 200
|
||||
assert data["file"] == "gateway"
|
||||
assert data["lines"] == []
|
||||
assert data["truncated"] is False
|
||||
assert data["total_bytes"] == 0
|
||||
assert data["mtime"] is None
|
||||
assert "not found" in data["hint"].lower()
|
||||
assert str(TEST_STATE_DIR) not in data["hint"]
|
||||
|
||||
|
||||
def test_logs_endpoint_tail_selector_is_allowlisted_and_defaults_to_200():
|
||||
logs_dir = TEST_STATE_DIR / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(logs_dir / "errors.log").write_text(
|
||||
"\n".join(f"2026-05-04 ERROR synthetic-log-marker line {i}" for i in range(250)) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
default_data, default_status = _get_logs("errors", "not-a-number")
|
||||
capped_data, capped_status = _get_logs("errors", 999999)
|
||||
allowed_data, allowed_status = _get_logs("errors", 100)
|
||||
|
||||
assert default_status == capped_status == allowed_status == 200
|
||||
assert default_data["tail"] == 200
|
||||
assert len(default_data["lines"]) == 200
|
||||
assert capped_data["tail"] == 200
|
||||
assert len(capped_data["lines"]) == 200
|
||||
assert allowed_data["tail"] == 100
|
||||
assert len(allowed_data["lines"]) == 100
|
||||
|
||||
|
||||
def test_logs_endpoint_reads_bounded_window_and_reports_truncation():
|
||||
logs_dir = TEST_STATE_DIR / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
huge_prefix = "x" * (4 * 1024 * 1024 + 64)
|
||||
(logs_dir / "gateway.log").write_text(
|
||||
huge_prefix + "\n2026-05-04 INFO synthetic-log-marker tail survives\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data, status = _get_logs("gateway", 1000)
|
||||
|
||||
assert status == 200
|
||||
assert data["tail"] == 1000
|
||||
assert data["truncated"] is True
|
||||
assert data["lines"][-1] == "2026-05-04 INFO synthetic-log-marker tail survives"
|
||||
assert data["total_bytes"] > 4 * 1024 * 1024
|
||||
|
||||
|
||||
def test_logs_endpoint_tests_use_only_synthetic_fixture_content():
|
||||
source = __import__("pathlib").Path(__file__).read_text(encoding="utf-8")
|
||||
assert "synthetic-log-marker" in source
|
||||
assert "/home/" + "michael" not in source
|
||||
assert "~/" + ".hermes/logs" not in source
|
||||
assert "TOK" + "EN=" not in source
|
||||
assert "PASS" + "WORD=" not in source
|
||||
139
tests/test_logs_ui_static.py
Normal file
139
tests/test_logs_ui_static.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
INDEX = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
PANELS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
assert match, f"{name}() not found"
|
||||
brace = src.find("{", match.end())
|
||||
assert brace != -1, f"{name}() has no body"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
while i < len(src) and depth:
|
||||
ch = src[i]
|
||||
nxt = src[i + 1] if i + 1 < len(src) else ""
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
i += 1
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
in_string = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name}() body did not close"
|
||||
return src[brace + 1:i - 1]
|
||||
|
||||
|
||||
def test_logs_tab_is_wired_between_insights_and_settings_in_rail_and_mobile_nav():
|
||||
rail = INDEX[INDEX.index('data-panel="insights"'):INDEX.index('<div class="rail-spacer"')]
|
||||
assert 'data-panel="logs"' in rail
|
||||
assert rail.index('data-panel="insights"') < rail.index('data-panel="logs"')
|
||||
|
||||
mobile_start = INDEX.index('class="sidebar-nav"')
|
||||
mobile_end = INDEX.index('<!-- Settings button mirrored here for mobile')
|
||||
mobile_nav = INDEX[mobile_start:mobile_end]
|
||||
assert 'data-panel="logs"' in mobile_nav
|
||||
assert mobile_nav.index('data-panel="insights"') < mobile_nav.index('data-panel="logs"')
|
||||
|
||||
assert 'id="panelLogs"' in INDEX
|
||||
assert 'id="mainLogs"' in INDEX
|
||||
assert "tab_logs" in I18N
|
||||
|
||||
|
||||
def test_logs_panel_fetches_allowlisted_api_and_exposes_controls():
|
||||
load_fn = _function_body(PANELS, "loadLogs")
|
||||
render_fn = _function_body(PANELS, "_renderLogs")
|
||||
selected_file_fn = _function_body(PANELS, "_selectedLogsFile")
|
||||
selected_tail_fn = _function_body(PANELS, "_selectedLogsTail")
|
||||
assert "api('/api/logs" in load_fn or 'api("/api/logs' in load_fn
|
||||
assert "logsFile" in selected_file_fn and "logsTail" in selected_tail_fn
|
||||
assert "agent" in INDEX and "errors" in INDEX and "gateway" in INDEX
|
||||
assert 'value="200" selected' in INDEX
|
||||
assert 'value="100"' in INDEX and 'value="500"' in INDEX and 'value="1000"' in INDEX
|
||||
assert "logsWrap" in INDEX
|
||||
assert "logsCopyAll" in INDEX
|
||||
assert "logsAutoRefresh" in INDEX
|
||||
assert "navigator.clipboard.writeText" in PANELS
|
||||
assert "logs-copy" in INDEX
|
||||
|
||||
|
||||
def test_logs_autorefresh_runs_only_while_logs_tab_is_visible_and_enabled():
|
||||
start_fn = _function_body(PANELS, "_startLogsAutoRefresh")
|
||||
stop_fn = _function_body(PANELS, "_stopLogsAutoRefresh")
|
||||
assert "if (nextPanel === 'logs') await loadLogs();" in PANELS
|
||||
assert "_syncLogsAutoRefresh();" in PANELS
|
||||
assert "_logsAutoRefreshTimer" in PANELS
|
||||
assert "setInterval" in start_fn and "5000" in start_fn
|
||||
assert "_currentPanel !== 'logs'" in start_fn
|
||||
assert "clearInterval" in stop_fn
|
||||
|
||||
|
||||
def test_logs_severity_coloring_prioritizes_explicit_log_level_before_message_text():
|
||||
severity_fn = _function_body(PANELS, "_logLineSeverityClass")
|
||||
# A WARNING message can legitimately contain words like "provider error";
|
||||
# color by the explicit level token, not by incidental message text.
|
||||
assert severity_fn.index("log-line-warning") < severity_fn.index("log-line-error")
|
||||
|
||||
|
||||
def test_logs_severity_coloring_and_monospace_wrap_css_are_present():
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".logs-output{" in css_min
|
||||
assert "font-family" in css_min and "monospace" in css_min
|
||||
assert ".logs-output.wrap" in css_min and "white-space:pre-wrap" in css_min
|
||||
for cls in ("log-line-error", "log-line-warning", "log-line-info", "log-line-debug"):
|
||||
assert f".{cls}" in css_min
|
||||
|
||||
|
||||
def test_logs_source_fixtures_do_not_bake_private_log_content():
|
||||
combined = "\n".join(
|
||||
(REPO / path).read_text(encoding="utf-8")
|
||||
for path in (
|
||||
"tests/test_logs_endpoint.py",
|
||||
"tests/test_logs_ui_static.py",
|
||||
"static/index.html",
|
||||
"static/panels.js",
|
||||
)
|
||||
)
|
||||
assert "/home/" + "michael/.hermes/logs" not in combined
|
||||
for name in ("agent", "gateway", "errors"):
|
||||
assert name + ".log:" not in combined
|
||||
85
tests/test_stage299_opus_fixes.py
Normal file
85
tests/test_stage299_opus_fixes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Regression test for the Opus SHOULD-FIX bounds applied in stage-299.
|
||||
|
||||
PR #1664 introduced /api/wiki/status with `_llm_wiki_count_files` and
|
||||
`_llm_wiki_page_files` that walk WIKI_PATH via `rglob`. Without bounds,
|
||||
a misconfigured WIKI_PATH=/ or symlink loop would hang the endpoint.
|
||||
|
||||
These tests pin the defenses applied per Opus advisor on stage-299:
|
||||
- A constant cap on iteration (_LLM_WIKI_MAX_FILES) for both functions
|
||||
- A forbidden-roots blocklist (_LLM_WIKI_FORBIDDEN_ROOTS) that includes
|
||||
'/' / '/etc' / '/usr' / '/var' / '/opt' / '/sys' / '/proc' (resolved
|
||||
to absolute strings)
|
||||
- Bounded behavior: if WIKI_PATH points at a forbidden root, both
|
||||
functions return 0/empty without iterating
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROUTES_PY = Path(__file__).parent.parent / "api" / "routes.py"
|
||||
|
||||
|
||||
def _read_source():
|
||||
return ROUTES_PY.read_text()
|
||||
|
||||
|
||||
def test_wiki_max_files_constant_present():
|
||||
src = _read_source()
|
||||
assert "_LLM_WIKI_MAX_FILES" in src
|
||||
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in src
|
||||
# Make sure cap is reasonable (≥ a few thousand, ≤ 100k)
|
||||
assert "10000" in src or "_LLM_WIKI_MAX_FILES = 10" in src
|
||||
|
||||
|
||||
def test_count_files_has_iteration_cap():
|
||||
src = _read_source()
|
||||
# Locate _llm_wiki_count_files body
|
||||
start = src.find("def _llm_wiki_count_files(")
|
||||
end = src.find("\ndef ", start + 1)
|
||||
body = src[start:end]
|
||||
assert "_LLM_WIKI_MAX_FILES" in body
|
||||
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in body
|
||||
assert "iterated > _LLM_WIKI_MAX_FILES" in body or "iterated >= _LLM_WIKI_MAX_FILES" in body
|
||||
|
||||
|
||||
def test_page_files_has_iteration_cap():
|
||||
src = _read_source()
|
||||
start = src.find("def _llm_wiki_page_files(")
|
||||
end = src.find("\ndef ", start + 1)
|
||||
body = src[start:end]
|
||||
assert "_LLM_WIKI_MAX_FILES" in body
|
||||
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in body
|
||||
|
||||
|
||||
def test_forbidden_roots_includes_system_paths():
|
||||
src = _read_source()
|
||||
# Find the constant definition
|
||||
start = src.find("_LLM_WIKI_FORBIDDEN_ROOTS = ")
|
||||
end = src.find(")\n", start) + 1
|
||||
decl = src[start:end + 1]
|
||||
for forbidden in ("/", "/etc", "/usr", "/var"):
|
||||
assert f'"{forbidden}"' in decl, f"Forbidden root {forbidden!r} not in _LLM_WIKI_FORBIDDEN_ROOTS"
|
||||
|
||||
|
||||
def test_count_files_returns_zero_for_forbidden_root(tmp_path, monkeypatch):
|
||||
"""Behavioral test: walking a forbidden root returns 0 without iterating."""
|
||||
import importlib
|
||||
routes = importlib.import_module("api.routes")
|
||||
|
||||
forbidden_root = Path("/etc")
|
||||
if forbidden_root.exists(): # skip on systems without /etc (Windows)
|
||||
result = routes._llm_wiki_count_files(forbidden_root)
|
||||
assert result == 0, "Walking /etc should return 0 (forbidden root guard)"
|
||||
|
||||
|
||||
def test_render_llm_wiki_status_uses_url_scheme_guard():
|
||||
"""Opus SHOULD-FIX #1: docs_url interpolated into href must be scheme-guarded."""
|
||||
panels_js = (Path(__file__).parent.parent / "static" / "panels.js").read_text()
|
||||
# Find the _renderLlmWikiStatus function body
|
||||
start = panels_js.find("function _renderLlmWikiStatus")
|
||||
end = panels_js.find("\nfunction ", start + 1)
|
||||
body = panels_js[start:end]
|
||||
# Must use a scheme-guarded form, not raw esc()
|
||||
assert "/^https?:" in body or "test(rawDocsUrl)" in body or "test(docsUrl)" in body, (
|
||||
"Expected URL scheme guard (e.g. /^https?:\\/\\//.test(...)) before "
|
||||
"interpolating docsUrl into href to prevent javascript: scheme XSS "
|
||||
"if docs_url ever becomes config-driven."
|
||||
)
|
||||
Reference in New Issue
Block a user