personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Harden health dashboard: XSS escaping, appEvents guard, incremental logs, stale sweep, timer lifecycle

- Escape all server-sourced strings in innerHTML across updateVitals, updateCortexGrid,
updateImporterGrid, updateDreamCard, updateSyncCard, and renderLogs service headers
- Guard window.appEvents.listen for availability
- Add incremental log append path (DOM append when following with unchanged filters)
- Add 60s stale sweep for agents and imports Maps (safety net for missed terminal events)
- Clear elapsedTimer when no agents remain; restarts on reappear
- Declare deepLinkMode: false in initial state object
- Replace undefined CSS custom properties (--text-muted, --text-secondary) with hardcoded colors

+94 -29
+94 -29
apps/health/workspace.html
··· 264 264 } 265 265 266 266 .observer-stream-qualifier { 267 - color: var(--text-muted); 267 + color: #6b7280; 268 268 min-width: 48px; 269 269 } 270 270 271 271 .observer-stream-mode { 272 - color: var(--text-secondary); 272 + color: #374151; 273 273 } 274 274 275 275 .observer-stream-row.stale .observer-stream-mode::after { 276 276 content: ' (stale)'; 277 - color: var(--text-muted); 277 + color: #6b7280; 278 278 } 279 279 280 280 /* Activity Grids Container */ ··· 806 806 logFollow: true, // Auto-scroll log viewport 807 807 observers: new Map(), // keyed by stream name 808 808 localHost: null, 809 + deepLinkMode: false, 810 + lastLogFilter: null, // Last rendered filter state for incremental append 809 811 }; 810 812 811 813 // DOM elements ··· 875 877 return String(id).slice(-4); 876 878 } 877 879 880 + function escapeHtml(text) { 881 + const div = document.createElement('div'); 882 + div.textContent = String(text ?? ''); 883 + return div.innerHTML; 884 + } 885 + 878 886 // Client-side elapsed time updater 879 887 let elapsedTimer = null; 880 888 function startElapsedTimer() { ··· 889 897 needsUpdate = true; 890 898 } 891 899 }); 900 + // Stop timer when no agents remain 901 + if (state.agents.size === 0) { 902 + clearInterval(elapsedTimer); 903 + elapsedTimer = null; 904 + return; 905 + } 892 906 if (needsUpdate) { 893 907 updateCortexGrid(); 894 908 } ··· 914 928 const servicesHtml = allServices.map(({ name, info, crashed }) => { 915 929 const statusClass = crashed ? 'crashed' : 'active'; 916 930 const dotClass = crashed ? 'service-dot crashed' : 'service-dot'; 917 - const restartInfo = crashed && info.restart_attempts ? ` (${info.restart_attempts}x)` : ''; 931 + const restartInfo = crashed && info.restart_attempts ? ` (${escapeHtml(info.restart_attempts)}x)` : ''; 918 932 return ` 919 933 <div class="${dotClass}"> 920 934 <span class="status-indicator ${statusClass}"></span> 921 - <span>${name}${restartInfo}</span> 935 + <span>${escapeHtml(name)}${restartInfo}</span> 922 936 </div> 923 937 `; 924 938 }).join(''); ··· 942 956 const staleNames = staleHeartbeats.join(', '); 943 957 elements.healthValue.innerHTML = ` 944 958 <span style="color: #f59e0b;">! ${staleCount} stale</span> 945 - <span class="stale-list">(${staleNames})</span> 959 + <span class="stale-list">(${escapeHtml(staleNames)})</span> 946 960 `; 947 961 updateVitalsStatus('warning'); 948 962 } else if (hasCrashed) { ··· 960 974 elements.queuesSection.style.display = ''; 961 975 elements.queuesValue.innerHTML = '<div class="vitals-chips">' + 962 976 queueEntries.map(([cmd, count]) => 963 - `<span class="vitals-chip">${cmd}: ${count}</span>` 977 + `<span class="vitals-chip">${escapeHtml(cmd)}: ${count}</span>` 964 978 ).join('') + '</div>'; 965 979 } else { 966 980 elements.queuesSection.style.display = 'none'; ··· 1260 1274 return ` 1261 1275 <div class="activity-card agent-active" data-agent-id="${agent.agent_id}"> 1262 1276 <div class="activity-card-id">...${getAgentId(agent.agent_id)}</div> 1263 - <div class="activity-card-name">${agent.name || 'default'}</div> 1264 - <div class="activity-card-state">${stateIcon} ${agent.event || 'unknown'}</div> 1277 + <div class="activity-card-name">${escapeHtml(agent.name || 'default')}</div> 1278 + <div class="activity-card-state">${stateIcon} ${escapeHtml(agent.event || 'unknown')}</div> 1265 1279 <div class="activity-card-elapsed">${elapsed}</div> 1266 - <div class="activity-card-provider">${agent.provider || 'unknown'}</div> 1280 + <div class="activity-card-provider">${escapeHtml(agent.provider || 'unknown')}</div> 1267 1281 </div> 1268 1282 `; 1269 1283 }).join(''); ··· 1292 1306 1293 1307 let errorHtml = ''; 1294 1308 if (isError && imp.error) { 1295 - errorHtml = `<div class="activity-card-error">${truncate(imp.error, 40)}</div>`; 1309 + errorHtml = `<div class="activity-card-error">${escapeHtml(truncate(imp.error, 40))}</div>`; 1296 1310 } 1297 1311 1298 1312 return ` 1299 1313 <div class="activity-card ${cardClass}" data-import-id="${imp.import_id}"> 1300 1314 <div class="activity-card-id">...${getAgentId(imp.import_id)}</div> 1301 - <div class="activity-card-name">${truncate(imp.input_file || 'import', 20)}</div> 1302 - <div class="activity-card-state">${isError ? '! ' : ''}${imp.stage || 'processing'}</div> 1315 + <div class="activity-card-name">${escapeHtml(truncate(imp.input_file || 'import', 20))}</div> 1316 + <div class="activity-card-state">${isError ? '! ' : ''}${escapeHtml(imp.stage || 'processing')}</div> 1303 1317 ${errorHtml} 1304 1318 <div class="activity-card-progress"> 1305 1319 <div class="activity-card-progress-bar" style="width: ${progress}%"></div> 1306 1320 </div> 1307 1321 <div class="activity-card-elapsed">${elapsed}</div> 1308 - <div class="activity-card-provider">${imp.file_type || 'unknown'}</div> 1322 + <div class="activity-card-provider">${escapeHtml(imp.file_type || 'unknown')}</div> 1309 1323 </div> 1310 1324 `; 1311 1325 }).join(''); ··· 1340 1354 1341 1355 // Info fields 1342 1356 const infoParts = []; 1343 - if (d.mode) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Mode</div><div>${d.mode}</div></div>`); 1344 - if (d.day) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Day</div><div>${d.day}</div></div>`); 1345 - if (d.facet) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Facet</div><div>${d.facet}</div></div>`); 1346 - if (d.segment) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Segment</div><div>${d.segment}</div></div>`); 1357 + if (d.mode) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Mode</div><div>${escapeHtml(d.mode)}</div></div>`); 1358 + if (d.day) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Day</div><div>${escapeHtml(d.day)}</div></div>`); 1359 + if (d.facet) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Facet</div><div>${escapeHtml(d.facet)}</div></div>`); 1360 + if (d.segment) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Segment</div><div>${escapeHtml(d.segment)}</div></div>`); 1347 1361 elements.dreamInfo.innerHTML = infoParts.join(''); 1348 1362 1349 1363 // Progress bars ··· 1392 1406 elements.syncCard.classList.remove('hidden'); 1393 1407 1394 1408 const infoParts = []; 1395 - if (s.host) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Host</div><div>${s.host}</div></div>`); 1396 - if (s.platform) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Platform</div><div>${s.platform}</div></div>`); 1409 + if (s.host) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Host</div><div>${escapeHtml(s.host)}</div></div>`); 1410 + if (s.platform) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Platform</div><div>${escapeHtml(s.platform)}</div></div>`); 1397 1411 infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Queue</div><div>${s.queue_size}</div></div>`); 1398 - if (s.state) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">State</div><div>${s.state}${s.confirm_attempt ? ' (' + s.confirm_attempt + ')' : ''}</div></div>`); 1399 - if (s.segment) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Segment</div><div>${s.segment}</div></div>`); 1412 + if (s.state) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">State</div><div>${escapeHtml(s.state)}${s.confirm_attempt ? ' (' + escapeHtml(s.confirm_attempt) + ')' : ''}</div></div>`); 1413 + if (s.segment) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Segment</div><div>${escapeHtml(s.segment)}</div></div>`); 1400 1414 elements.syncInfo.innerHTML = infoParts.join(''); 1401 1415 } 1402 1416 ··· 1435 1449 elements.logServiceFilter.appendChild(opt); 1436 1450 } 1437 1451 1438 - renderLogs(); 1452 + renderLogs(name, record); 1439 1453 } 1440 1454 1441 - function renderLogs() { 1455 + function renderLogs(newService, newRecord) { 1442 1456 if (state.deepLinkMode) return; 1443 1457 const serviceFilter = elements.logServiceFilter.value; 1444 1458 const streamFilter = elements.logStreamFilter.value; 1445 1459 const viewport = elements.logsViewport; 1460 + const filterKey = serviceFilter + ':' + streamFilter; 1461 + 1462 + // Incremental append: when following, filters unchanged, and we have a new record 1463 + if (newRecord && state.logFollow && state.lastLogFilter === filterKey && viewport.children.length > 0) { 1464 + const serviceMatch = serviceFilter === 'all' || serviceFilter === newService; 1465 + const streamMatch = streamFilter === 'all' || streamFilter === newRecord.stream; 1466 + if (serviceMatch && streamMatch) { 1467 + if (serviceFilter === 'all' && !viewport.querySelector(`[data-svc="${CSS.escape(newService)}"]`)) { 1468 + const header = document.createElement('div'); 1469 + header.className = 'logs-service-header'; 1470 + header.setAttribute('data-svc', newService); 1471 + header.textContent = '── ' + newService + ' ──'; 1472 + viewport.appendChild(header); 1473 + } 1474 + const line = document.createElement('div'); 1475 + line.className = newRecord.stream === 'stderr' ? 'logs-line stderr' : newRecord.stream === 'log' ? 'logs-line log' : 'logs-line'; 1476 + line.textContent = newRecord.line; 1477 + viewport.appendChild(line); 1478 + viewport.scrollTop = viewport.scrollHeight; 1479 + return; 1480 + } 1481 + } 1482 + 1483 + state.lastLogFilter = filterKey; 1446 1484 1447 1485 // Check if user has scrolled away from bottom before updating 1448 1486 const wasAtBottom = state.logFollow && (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 50); ··· 1458 1496 const filtered = streamFilter === 'all' ? lines : lines.filter(l => l.stream === streamFilter); 1459 1497 if (filtered.length === 0) continue; 1460 1498 1461 - html += `<div class="logs-service-header">── ${svc} ──</div>`; 1499 + html += `<div class="logs-service-header" data-svc="${escapeHtml(svc)}">── ${escapeHtml(svc)} ──</div>`; 1462 1500 for (const rec of filtered) { 1463 1501 const cls = rec.stream === 'stderr' ? 'logs-line stderr' : rec.stream === 'log' ? 'logs-line log' : 'logs-line'; 1464 1502 const escaped = rec.line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); ··· 1599 1637 file_type: msg.file_type, 1600 1638 stage: msg.stage, 1601 1639 event: 'started', 1602 - elapsed_ms: 0 1640 + elapsed_ms: 0, 1641 + lastSeen: Date.now() 1603 1642 }); 1604 1643 } else if (msg.event === 'status') { 1605 1644 const existing = state.imports.get(importId) || {}; ··· 1607 1646 ...existing, 1608 1647 stage: msg.stage, 1609 1648 elapsed_ms: msg.elapsed_ms, 1610 - event: 'status' 1649 + event: 'status', 1650 + lastSeen: Date.now() 1611 1651 }); 1612 1652 } else if (msg.event === 'completed') { 1613 1653 const existing = state.imports.get(importId) || {}; ··· 1626 1666 state.imports.set(importId, { 1627 1667 ...existing, 1628 1668 event: 'error', 1629 - error: msg.error || 'Unknown error' 1669 + error: msg.error || 'Unknown error', 1670 + lastSeen: Date.now() 1630 1671 }); 1631 1672 1632 1673 // Keep error visible longer ··· 1716 1757 } 1717 1758 1718 1759 // Listen to all Callosum events 1719 - window.appEvents.listen('*', handleEvent); 1760 + if (window.appEvents) { 1761 + window.appEvents.listen('*', handleEvent); 1762 + } 1763 + 1764 + // Sweep stale agents and imports every 60s 1765 + setInterval(() => { 1766 + const cutoff = Date.now() - 5 * 60 * 1000; 1767 + let changed = false; 1768 + state.agents.forEach((agent, id) => { 1769 + if ((agent.ts || 0) < cutoff) { 1770 + state.agents.delete(id); 1771 + changed = true; 1772 + } 1773 + }); 1774 + if (changed) updateCortexGrid(); 1775 + 1776 + changed = false; 1777 + state.imports.forEach((imp, id) => { 1778 + if ((imp.lastSeen || 0) < cutoff) { 1779 + state.imports.delete(id); 1780 + changed = true; 1781 + } 1782 + }); 1783 + if (changed) updateImporterGrid(); 1784 + }, 60000); 1720 1785 })(); 1721 1786 </script>