agent-orchestrator/ui-electrobun/src/mainview/DocsTab.svelte
Hibryda 1cd4558740 fix(electrobun): address all 22 Codex review #2 findings
CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs

HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle

MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`

LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
2026-03-22 02:30:09 +01:00

240 lines
7.3 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import DOMPurify from 'dompurify';
import { appRpc } from './rpc.ts';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
interface DocFile {
name: string;
path: string;
}
let files = $state<DocFile[]>([]);
let selectedFile = $state<DocFile | null>(null);
let content = $state('');
let renderedHtml = $state('');
let loading = $state(false);
// Fix #1: Configure DOMPurify with safe tag whitelist
const PURIFY_CONFIG = {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'li', 'br'],
ALLOWED_ATTR: ['href', 'target', 'class'],
};
function expandHome(p: string): string {
if (p.startsWith('~/')) return p.replace('~', '/home/' + (typeof process !== 'undefined' ? process.env.USER : 'user'));
return p;
}
async function loadFiles() {
try {
const resolved = expandHome(cwd);
const res = await appRpc.request['files.list']({ path: resolved });
if (res.error) return;
files = res.entries
.filter((e: { name: string; type: string }) => e.type === 'file' && e.name.endsWith('.md'))
.map((e: { name: string }) => ({ name: e.name, path: `${resolved}/${e.name}` }));
} catch (err) {
console.error('[DocsTab] list error:', err);
}
}
async function selectFile(file: DocFile) {
selectedFile = file;
loading = true;
try {
const res = await appRpc.request['files.read']({ path: file.path });
if (res.error || !res.content) {
content = '';
renderedHtml = DOMPurify.sanitize(
`<p class="doc-error">${escapeHtml(res.error ?? 'Empty file')}</p>`,
PURIFY_CONFIG,
);
} else {
content = res.content;
renderedHtml = DOMPurify.sanitize(renderMarkdown(content), PURIFY_CONFIG);
}
} catch (err) {
console.error('[DocsTab] read error:', err);
renderedHtml = DOMPurify.sanitize('<p class="doc-error">Failed to read file</p>', PURIFY_CONFIG);
}
loading = false;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Simple markdown-to-HTML. All output is sanitized by DOMPurify before rendering. */
function renderMarkdown(md: string): string {
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
`<pre class="doc-code"><code>${code.trim()}</code></pre>`
);
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
// Headers
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold + italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
html = html.replace(/<\/ul>\s*<ul>/g, '');
// Paragraphs (lines not already wrapped)
html = html.replace(/^(?!<[huplo])((?!\s*$).+)$/gm, '<p>$1</p>');
return html;
}
onMount(() => { loadFiles(); });
</script>
<div class="docs-tab">
<div class="docs-sidebar">
<div class="docs-header">Markdown files</div>
{#if files.length === 0}
<div class="docs-empty">No .md files in project</div>
{:else}
{#each files as file}
<button
class="docs-file"
class:active={selectedFile?.path === file.path}
onclick={() => selectFile(file)}
>{file.name}</button>
{/each}
{/if}
</div>
<div class="docs-content">
{#if loading}
<div class="docs-placeholder">Loading...</div>
{:else if !selectedFile}
<div class="docs-placeholder">Select a file to view</div>
{:else}
<div class="docs-rendered">
{@html renderedHtml}
</div>
{/if}
</div>
</div>
<style>
.docs-tab { display: flex; height: 100%; overflow: hidden; }
.docs-sidebar {
width: 10rem;
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.docs-header {
padding: 0.375rem 0.5rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
border-bottom: 1px solid var(--ctp-surface0);
}
.docs-empty {
padding: 1rem 0.5rem;
font-size: 0.75rem;
color: var(--ctp-overlay0);
font-style: italic;
text-align: center;
}
.docs-file {
display: block;
width: 100%;
text-align: left;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.75rem;
cursor: pointer;
font-family: var(--term-font-family);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background 0.1s, color 0.1s;
}
.docs-file:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.docs-file.active { background: color-mix(in srgb, var(--ctp-blue) 12%, transparent); color: var(--ctp-blue); }
.docs-content { flex: 1; overflow-y: auto; padding: 0.75rem; min-width: 0; }
.docs-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
.docs-rendered {
font-family: var(--ui-font-family);
font-size: 0.8125rem;
color: var(--ctp-text);
line-height: 1.6;
}
.docs-rendered :global(h1) { font-size: 1.25rem; color: var(--ctp-text); margin: 0.75rem 0 0.375rem; }
.docs-rendered :global(h2) { font-size: 1.0625rem; color: var(--ctp-text); margin: 0.625rem 0 0.3rem; }
.docs-rendered :global(h3) { font-size: 0.9375rem; color: var(--ctp-text); margin: 0.5rem 0 0.25rem; }
.docs-rendered :global(p) { margin: 0.25rem 0; }
.docs-rendered :global(a) { color: var(--ctp-blue); text-decoration: none; }
.docs-rendered :global(a:hover) { text-decoration: underline; }
.docs-rendered :global(strong) { color: var(--ctp-text); }
.docs-rendered :global(ul) { padding-left: 1.25rem; margin: 0.25rem 0; }
.docs-rendered :global(li) { margin: 0.125rem 0; }
.docs-rendered :global(.doc-code) {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
padding: 0.5rem 0.625rem;
overflow-x: auto;
font-family: var(--term-font-family);
font-size: 0.75rem;
line-height: 1.5;
margin: 0.375rem 0;
}
.docs-rendered :global(.doc-inline-code) {
background: var(--ctp-surface0);
padding: 0.1rem 0.25rem;
border-radius: 0.2rem;
font-family: var(--term-font-family);
font-size: 0.75rem;
}
.docs-rendered :global(.doc-error) { color: var(--ctp-red); font-style: italic; }
</style>