Date: 2026-03-20 Phase: 10 (Hardened Cgroup Agents) Affected components: Web Dashboard (guardian binary)
The SSE (Server-Sent Events) connection status indicator on the Overview and Events pages permanently displayed "Reconnecting..." even though the SSE stream was working correctly on the server side.
Editing agent policies (file access rules, exec policy default/allow/deny) on the /policy page and clicking "Save Policy" produced no feedback and did not persist changes. The URL bar showed the form data as query parameters — a clear sign of a regular GET form submission instead of an AJAX request.
Cause A: Race condition on page load
The SSE connection (EventSource) is created in an inline <script> IIFE in base.html. Alpine.js is loaded with defer, so it initializes after the DOM is fully parsed. If the SSE onopen event fires before Alpine registers its guardian:sse-open listener, the connected state is never set to true.
Timeline of the race:
1. base.html IIFE runs → EventSource('/events/stream') created
2. SSE connection opens → sse.onopen fires → dispatches 'guardian:sse-open'
↑ No Alpine listener registered yet — event is lost
3. Alpine.js loads (defer) → component init() runs
4. init() checks readyState — may or may not be OPEN at this exact moment
5. Registers listener for 'guardian:sse-open' — but the event already fired
6. connected stays false → "Reconnecting..." displayed forever
Cause B: Aggressive error state on normal reconnects
The EventSource.onerror callback fired on any error, including the normal browser auto-reconnect cycle. Even a momentary reconnect (e.g., TCP keepalive timeout) would immediately set connected = false, and the UI showed "Reconnecting..." before onopen could fire again. With high event volume (30+ events/sec from Claude Code monitoring), brief reconnects were frequent.
The htmx library was loaded from the unpkg CDN with a Subresource Integrity (SRI) hash:
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-M06VwgoUOHG3FN0UchwWKqh9jS4ejwpoL0yjF3EVljtsxFwFETEYMkyNL5lXbJ5/"
crossorigin="anonymous"></script>The actual SHA-384 hash of the file served by unpkg was:
sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+
These did not match. The browser's SRI check silently blocked htmx from loading. Without htmx:
- All
hx-put,hx-post,hx-getattributes were ignored - The
<form hx-put="/api/policy/...">had noactionormethodattributes - The browser fell back to a default GET submission to the current URL
- The policy page reloaded with form data as query parameters (visible in the URL bar)
- No data was sent to the API endpoint — the update never happened
This also broke: the "Reload Config" sidebar button, the auto-refreshing status cards, agent stop/grant buttons, and alert configuration saves.
Files changed:
guardian/static/htmx.min.js— new file (htmx 2.0.4, 51KB)guardian/static/alpine.min.js— new file (Alpine.js 3.14.8, 45KB)guardian/templates/base.html— updated script tags
Before:
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-..." crossorigin="anonymous"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js" integrity="sha384-..." crossorigin="anonymous"></script>After:
<script src="/static/htmx.min.js"></script>
<script defer src="/static/alpine.min.js"></script>Why: Eliminates CDN dependency entirely. The files are embedded into the guardian binary via rust-embed at compile time. The dashboard now works fully offline — no internet required. SRI hash mismatches can never occur.
Trade-off: Binary size increases by ~96KB (compressed). This is negligible for a security daemon.
File changed: guardian/templates/base.html
Removed the htmx-ext-sse@2.2.2 script tag. The SSE connection is managed by custom JavaScript (EventSource API), not htmx's SSE extension. The extension was loaded but never used (no hx-ext="sse" or sse-connect attributes in any template). Removing it eliminates a potential source of interference and one fewer CDN dependency.
Files changed:
guardian/templates/base.html— SSE IIFE rewrittenguardian/templates/index.html— init() updatedguardian/templates/events.html— init() updated
Changes to base.html IIFE:
-
Window-level state tracking:
window.__guardianSSEConnectedis set inonopen/onerrorcallbacks so Alpine components can read the state even if the event was dispatched before their listener was registered. -
Debounced error state: Instead of immediately marking as disconnected on
onerror, a 2-second timer starts. Ifonopenfires within 2 seconds (normal reconnect), the timer is cancelled and the status stays "Connected". Only persistent disconnections (>2s) show "Reconnecting...". -
Event-based auto-recovery: Receiving any SSE data event (
eventorpermission) automatically setsconnected = true, regardless of whetheronopenwas missed.
Changes to index.html and events.html:
-
Initial state from window:
self.connected = window.__guardianSSEConnected || readyState === OPENhandles the race condition whereonopenfires before Alpine init. -
Periodic self-heal: A 3-second
setIntervalchecksEventSource.readyStatedirectly and correctsconnected = trueif the connection is open. This catches any edge cases where both the event and the window flag were missed.
Files changed:
guardian/templates/policy.html— form attributes updatedguardian/src/dashboard/mod.rs— route updatedguardian/src/dashboard/routes/api.rs— handler updated
-
POST route added alongside PUT: The policy endpoint now accepts both
PUT(htmx) andPOST(fallback) methods. -
Auto-reload after save: The form now includes
hx-on::after-requestto reload the page 1.5 seconds after a successful save, giving visual confirmation that the policy was applied. -
Config reload after write: After writing the config to disk, the handler now re-reads the config file into memory to ensure consistency. The success message changed from "Send SIGHUP or use reload to apply" to "Policy saved and applied."
File changed: guardian/src/dashboard/routes/api.rs (write_config_toml)
Added serialization for fields that were previously silently dropped when saving config from the dashboard:
| Field | Section | Risk if lost |
|---|---|---|
auth_token |
[dashboard] |
Dashboard becomes unauthenticated after save |
network_policy |
[[agents]] |
Network deny/allow ports lost |
fail_closed |
[[agents]] |
Fail-closed enforcement disabled |
After rebuilding and restarting the daemon:
- SSE status: Overview and Events pages should show "Live" / "Connected" within 3 seconds of page load
- Policy update: Edit any agent's policy on
/policy, click "Save Policy" → green toast appears → page auto-reloads with updated values - Offline operation: Dashboard works without internet access (no CDN requests in browser dev tools Network tab)
- htmx features: "Reload Config" sidebar button works, status cards auto-refresh every 5s, agent stop/grant buttons work
| File | Change |
|---|---|
guardian/static/htmx.min.js |
New — embedded htmx 2.0.4 |
guardian/static/alpine.min.js |
New — embedded Alpine.js 3.14.8 |
guardian/static/app.js |
Cleaned up dead htmx-ext-sse code |
guardian/templates/base.html |
Local JS, SSE debounce, removed htmx-ext-sse |
guardian/templates/index.html |
Robust SSE connected state |
guardian/templates/events.html |
Robust SSE connected state |
guardian/templates/policy.html |
Auto-reload after save |
guardian/src/dashboard/mod.rs |
POST fallback route for policy |
guardian/src/dashboard/routes/api.rs |
Config reload after save, serialization fixes |