Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions cmd/odek/ui/js/render.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Message rendering: streaming, thinking, tool blocks, sub-agent cards,
// session-history rendering, collapse/copy affordances, and the loading
// indicator. Imports only from state/dom/utils/markdown.
// indicator. Imports only from state/dom/utils/markdown/untrusted.
import { S } from './state.js';
import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js';
import {
Expand All @@ -9,6 +9,7 @@ import {
showCancel, hideCancel, announce,
} from './utils.js';
import { markdownToHtml } from './markdown.js';
import { parseUntrusted } from './untrusted.js';

// ── Turn state ──
// resetTurnState clears all per-turn streaming/tool/sub-agent state. Called
Expand Down Expand Up @@ -230,7 +231,7 @@ export function addMessage(role, content) {
wrapper.innerHTML =
'<div class="bubble">' +
'<div class="sender">' + sender + '</div>' +
'<div class="content">' + markdownToHtml(escapeHtml(content)) + '</div>' +
'<div class="content">' + markdownToHtml(content) + '</div>' +
'</div>';
messagesEl.appendChild(wrapper);
// Copy button and collapse check on the freshly appended bubble.
Expand Down Expand Up @@ -270,7 +271,7 @@ export function renderAssistantMessage(content) {
wrapper.innerHTML =
'<div class="bubble">' +
'<div class="sender">assistant</div>' +
'<div class="content">' + markdownToHtml(escapeHtml(content)) + '</div>' +
'<div class="content">' + markdownToHtml(content) + '</div>' +
'</div>';
messagesEl.appendChild(wrapper);
const bubble = wrapper.querySelector('.bubble');
Expand Down Expand Up @@ -375,18 +376,42 @@ export function addToolCall(name, data) {
// long output behind a "show all" expander. Shared by the live path
// (addToolResult) and session-history rendering.
function appendToolResultContent(block, output) {
const MAX_RESULT = 600;
const truncated = output && output.length > MAX_RESULT;
const resultEl = document.createElement('div');
resultEl.className = 'tb-result';
block.appendChild(resultEl);
if (truncated) {
resultEl.innerHTML =
escapeHtml(output.slice(0, MAX_RESULT)) +
'<span class="tb-result-more" role="button" tabindex="0" data-full="' +
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
} else {
resultEl.textContent = output || '';
fillToolResult(resultEl, output || '', true);
}

// fillToolResult renders tool output into resultEl. The server sends raw,
// unsanitized content; tool output may embed the model-facing
// <untrusted_content_*> envelope, which is unwrapped for display — the body
// is inserted as text (never HTML) and the envelope source is shown as a
// badge instead of the literal tag text. When truncate is true, long bodies
// are cut behind a "show all" expander carrying the full output.
function fillToolResult(resultEl, output, truncate) {
const MAX_RESULT = 600;
const segments = parseUntrusted(output);
for (const seg of segments) {
if (seg.source) {
const badge = document.createElement('span');
badge.className = 'tb-source';
badge.textContent = '🔒 ' + seg.source;
resultEl.appendChild(badge);
resultEl.appendChild(document.createTextNode('\n'));
}
const body = seg.body;
if (truncate && body.length > MAX_RESULT) {
resultEl.appendChild(document.createTextNode(body.slice(0, MAX_RESULT)));
const more = document.createElement('span');
more.className = 'tb-result-more';
more.setAttribute('role', 'button');
more.tabIndex = 0;
more.dataset.full = output;
more.textContent = ' …show all (' + body.length + ' chars)';
resultEl.appendChild(more);
} else {
resultEl.appendChild(document.createTextNode(body));
}
}
}

Expand Down Expand Up @@ -414,7 +439,10 @@ export function addToolResult(name, output) {
function expandToolResult(el) {
const full = el.dataset.full || '';
const resultEl = el.parentElement;
if (resultEl) resultEl.textContent = full;
if (resultEl) {
resultEl.textContent = '';
fillToolResult(resultEl, full, false);
}
}

function toggleToolBody(header) {
Expand Down
73 changes: 73 additions & 0 deletions cmd/odek/ui/js/untrusted.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Client-side parser for the server-side untrusted-content envelope
// (<untrusted_content_<nonce> source="...">…</untrusted_content_<nonce>>),
// mirroring the grammar produced by wrapUntrusted in cmd/odek/untrusted.go.
//
// The envelope is model-facing trust metadata, not user content: the WebUI
// unwraps it for display (body shown escaped, source as a badge) instead of
// rendering the literal tag text. Sanitization itself stays client-side —
// the server always sends raw content.
//
// Server-side neutraliseWrapperLiterals guarantees bodies contain no literal
// "untrusted_content" substring, so non-greedy matching cannot terminate
// early inside a body.

// Nonce-backreferenced: the closing tag must repeat the opening nonce, so a
// forged/mismatched envelope is treated as plain text rather than parsed.
const RE_UNTRUSTED =
/<untrusted_content_([0-9a-f]+) source="([^"]*)">\n?([\s\S]*?)\n?<\/untrusted_content_\1>/g;

// parseUntrusted splits text into segments: wrapped envelopes become
// { source, body } (body trimmed of the envelope's framing newlines) and any
// surrounding plain text becomes { source: null, body }. Empty plain-text
// gaps between envelopes are omitted.
export function parseUntrusted(text) {
if (!text) return [];
const segments = [];
let last = 0;
RE_UNTRUSTED.lastIndex = 0;
let m;
while ((m = RE_UNTRUSTED.exec(text)) !== null) {
if (m.index > last) {
segments.push({ source: null, body: text.slice(last, m.index) });
}
segments.push({ source: m[2], body: m[3] });
last = m.index + m[0].length;
}
if (last < text.length) {
segments.push({ source: null, body: text.slice(last) });
}
if (segments.length === 0) {
segments.push({ source: null, body: text });
}
return segments;
}

// unwrapUntrusted returns the concatenated bodies of every envelope in text
// (plus any non-wrapped text), with all envelope tags removed.
export function unwrapUntrusted(text) {
return parseUntrusted(text).map((s) => s.body).join('');
}

// hasUntrustedWrapper reports whether text contains at least one complete
// nonce-matched envelope.
export function hasUntrustedWrapper(text) {
if (!text) return false;
RE_UNTRUSTED.lastIndex = 0;
return RE_UNTRUSTED.test(text);
}

// stripAttachmentBodies collapses attachment envelopes in reloaded user
// messages to chip-style placeholders so session history doesn't dump file
// bodies. The server stores each attachment as an envelope with
// source="attachment:<name>" (serve.go); this matches the 📎 chip rendering
// used at send time (input.js). All other segments pass through unwrapped
// (envelope tags removed, bodies kept).
export function stripAttachmentBodies(content) {
if (!content) return '';
return parseUntrusted(content).map((seg) => {
if (seg.source && seg.source.startsWith('attachment:')) {
return '📎 ' + seg.source.slice('attachment:'.length) + '\n';
}
return seg.body;
}).join('');
}
80 changes: 80 additions & 0 deletions cmd/odek/ui/js/untrusted.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Golden tests for the untrusted-content envelope parser (untrusted.js).
// Mirrors the grammar produced by wrapUntrusted in cmd/odek/untrusted.go and
// pins the client-side-sanitization contract: the server sends raw content,
// the client unwraps the model-facing envelope and escapes the body itself.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseUntrusted, unwrapUntrusted, hasUntrustedWrapper, stripAttachmentBodies,
} from './untrusted.js';
import { escapeHtml } from './escape.js';
import { markdownToHtml } from './markdown.js';

function wrap(nonce, source, body) {
return '<untrusted_content_' + nonce + ' source="' + source + '">\n' +
body + '\n</untrusted_content_' + nonce + '>';
}

test('single envelope: body extracted, source captured', () => {
const segs = parseUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world'));
assert.deepEqual(segs, [{ source: 'shell', body: 'hello world' }]);
assert.equal(unwrapUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world')), 'hello world');
});

test('multiple envelopes in one payload keep order', () => {
const text = wrap('aaaaaaaaaaaaaaaa', 'shell', 'one') +
'\nplain between\n' +
wrap('bbbbbbbbbbbbbbbb', 'browser:https://example.com', 'two');
const segs = parseUntrusted(text);
assert.deepEqual(segs, [
{ source: 'shell', body: 'one' },
{ source: null, body: '\nplain between\n' },
{ source: 'browser:https://example.com', body: 'two' },
]);
});

test('nonce mismatch between open and close is not parsed', () => {
const forged = '<untrusted_content_aaaaaaaaaaaaaaaa source="shell">\n' +
'body\n</untrusted_content_bbbbbbbbbbbbbbbb>';
assert.deepEqual(parseUntrusted(forged), [{ source: null, body: forged }]);
assert.equal(hasUntrustedWrapper(forged), false);
});

test('text without envelope passes through unchanged', () => {
assert.deepEqual(parseUntrusted('plain output'), [{ source: null, body: 'plain output' }]);
assert.equal(unwrapUntrusted('plain output'), 'plain output');
assert.equal(hasUntrustedWrapper('plain output'), false);
});

test('empty and nullish input', () => {
assert.deepEqual(parseUntrusted(''), []);
assert.equal(unwrapUntrusted(''), '');
assert.equal(hasUntrustedWrapper(''), false);
});

test('body containing HTML survives parsing raw', () => {
const body = '<b>bold</b> & <script>alert(1)</script>';
const segs = parseUntrusted(wrap('0123456789abcdef', 'shell', body));
assert.equal(segs[0].body, body);
});

test('render chain escapes unwrapped bodies (client-side contract)', () => {
const wrapped = wrap('0123456789abcdef', 'shell', '<script>alert(1)</script>');
const body = unwrapUntrusted(wrapped);
assert.equal(escapeHtml(body), '&lt;script&gt;alert(1)&lt;/script&gt;');
// markdownToHtml escapes all input by default — callers must NOT pre-escape.
assert.equal(markdownToHtml(body), '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
// Pin the double-escape regression: pre-escaping renders &lt; literally.
assert.equal(markdownToHtml(escapeHtml(body)), '<p>&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;</p>');
});

test('attachment envelope collapses to a chip, bodies dropped', () => {
const text = wrap('a1a1a1a1a1a1a1a1', 'attachment:notes.txt', '--- notes.txt ---\nfile body here') +
'\n\nwhat is in this file?';
assert.equal(stripAttachmentBodies(text), '📎 notes.txt\n\n\nwhat is in this file?');
});

test('non-attachment envelopes pass through unwrapped', () => {
const text = wrap('c3c3c3c3c3c3c3c3', 'resource:@README.md', 'readme body');
assert.equal(stripAttachmentBodies(text), 'readme body');
});
14 changes: 5 additions & 9 deletions cmd/odek/ui/js/utils.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// Pure-ish helpers: escaping, formatting, clipboard, toast, scrolling, and
// small DOM toggles. Imports only from state.js and dom.js.
// small DOM toggles. Imports only from state.js, dom.js, and untrusted.js.
import { S } from './state.js';
import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js';

// stripAttachmentBodies lives in untrusted.js (pure, node-testable);
// re-exported here so render.js keeps a single utils import.
export { stripAttachmentBodies } from './untrusted.js';

// ── Escape helpers (implemented in escape.js so markdown.js can use them
// without importing the browser-dependent modules) ──
export { escapeHtml, escapeAttr } from './escape.js';
Expand Down Expand Up @@ -108,14 +112,6 @@ export function pruneMessages() {
}
}

// Replace inlined attachment blocks (--- name (size) ---\n...\n--- end name ---)
// with chip-style placeholders so reloaded user messages don't dump file bodies.
export function stripAttachmentBodies(content) {
if (!content) return '';
const re = /^--- (.+?) \(([^)]+)\) ---\n[\s\S]*?\n--- end \1 ---\n?/gm;
return content.replace(re, (m, name, size) => '📎 ' + name + ' (' + size + ')\n');
}

// ── Error message normalization ──
export function formatErrorMessage(msg) {
if (!msg) return 'Unknown error';
Expand Down
11 changes: 11 additions & 0 deletions cmd/odek/ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,17 @@ body.light {
letter-spacing: .04em;
}
.tb-result-more:hover { text-decoration: underline; }
.tb-source {
display: inline-block;
font-size: 10px;
color: var(--text-3);
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 3px;
padding: 1px 6px;
margin-bottom: 2px;
white-space: nowrap;
}

/* ── Sub-agent cards ───────────────────────────────────────────────── */
.subagent-group { margin: 12px 0; }
Expand Down
56 changes: 46 additions & 10 deletions docs/WEBUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,25 +119,58 @@ The UI communicates entirely over a single WebSocket at `/ws`. Messages are newl

| Event Type | When | Fields |
|------------|------|--------|
| `session` | At start of response | `session_id`, `model` |
| `session` | At start of response | `session_id`, `auth_token`, `model`, `sandbox` |
| `token` | Streamed text content | `content` (markdown) |
| `tool_call` | Agent invokes a tool | `name`, `command` |
| `tool_result` | Tool returns output | `name`, `output` (truncated to 500 chars) |
| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `sessionContextTokens`, `sessionOutputTokens` |
| `thinking` | Streamed reasoning content | `content` |
| `tool_call` | Agent invokes a tool | `name`, `data` (raw tool-arguments JSON) |
| `tool_result` | Tool returns output | `name`, `data` (full, untruncated output) |
| `subagent_log` | Sub-agent progress within `delegate_tasks` | `task_idx`, `name`, `event`, `data` |
| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `cacheCreationTokens`, `cacheReadTokens`, `cachedTokens`, `sessionContextTokens`, `sessionOutputTokens` |
| `error` | Agent or server error | `message` |
| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation` |
| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation`, `allow_trust`, `friction`, `friction_approvals` |
| `approval_ack` | Server confirms an approval response | `id`, `action` |
| `skill_event` | Skill lifecycle event | `event`, `skill_name`, `skills`, `heuristic` |
| `memory_event` | Memory lifecycle event | `event`, `target`, `session_id`, `content`, `count`, `new_count`, `untrusted` |
| `agent_signal` | Agent self-observability signal | `event`, `detail`, `tool`, `count` |

Example event sequence:

```jsonc
{"type":"session","session_id":"20260519-x1y2z3","model":"deepseek-v4-flash"}
{"type":"token","content":"Let me look at the source directory."}
{"type":"tool_call","name":"shell","command":"ls -la src/"}
{"type":"tool_result","name":"shell","output":"total 24\ndrwxr-xr-x ..."}
{"type":"tool_call","name":"shell","data":"{\"command\":\"ls -la src/\"}"}
{"type":"tool_result","name":"shell","data":"<untrusted_content_a1b2c3d4 source=\"shell\">\ntotal 24\ndrwxr-xr-x ...\n</untrusted_content_a1b2c3d4>"}
{"type":"token","content":"The `src/` directory contains 3 files:"}
{"type":"done","latency":4.2}
```

### Content sanitization contract

The server sends all message content **raw and unsanitized**. HTML-escaping
is the client's responsibility: any frontend (the bundled WebUI or a
third-party client) MUST escape/sanitize every string field before inserting
it into a DOM, terminal UI, or other rendering surface. Untrusted fields
include `token.content`, `thinking.content`, `tool_call.data`,
`tool_result.data`, `subagent_log.data`, `error.message`, all
`approval_request` strings, `skill_event.*`, `memory_event.*`, and
`agent_signal.*`.

Tool results (and user messages containing attachments or `@`-resource
references) may embed the nonce'd untrusted-content envelope:

```
<untrusted_content_<nonce> source="shell">
...raw body...
</untrusted_content_<nonce>>
```

The envelope is **model-facing trust metadata** (prompt-injection defense),
not user content. Clients SHOULD unwrap it for display: render the body
(escaped) and, optionally, present the `source` attribute as a badge. The
closing tag repeats the opening nonce — treat envelopes whose nonces don't
match as plain text. The bundled WebUI implements this in
`cmd/odek/ui/js/untrusted.js`.

## Implementation details

### Server stack (`cmd/odek/serve.go`)
Expand All @@ -159,14 +192,17 @@ Example event sequence:
- Thread-safe writes via `sync.Mutex`
- Error handling: returns `io.EOF` on clean close, raw `net.Error` on broken connection

### Frontend (`cmd/odek/ui/index.html`)
### Frontend (`cmd/odek/ui/`)

- ~1,200 lines: single file with embedded CSS and vanilla JS (no frameworks)
- Vanilla JS + CSS SPA split into native ES modules under `js/` (state, dom, utils, escape, markdown, untrusted, render, approvals, sessions, input, ws, main, net) — no build step
- **Escaping**: all server-controlled strings are inserted escaped (`escapeHtml`/`escapeAttr`/`textContent`); `markdownToHtml` HTML-escapes all input by default and allowlists link schemes — see "Content sanitization contract" above
- **Untrusted envelope**: `js/untrusted.js` unwraps the model-facing `<untrusted_content_*>` envelope before display (body shown, source as badge)
- **Design system**: loaded from `https://assets.21no.de/css/tokens.css` — dark theme with CSS custom properties (`--bg-primary`, `--accent`, `--text-primary`, etc.)
- **Typeface**: loaded from `https://assets.21no.de/fonts/fonts.css` — uses `var(--font-sans)` and `var(--font-mono)`
- **Streaming**: token content is batch-rendered via `requestAnimationFrame` to avoid layout thrashing
- **DOM budget**: message list is capped at 100 elements (`MAX_MESSAGES`), older messages are pruned
- **DOM budget**: message list is capped at 80 elements (`MAX_MESSAGES`), older messages are pruned
- **Resilience**: auto-reconnects WebSocket on disconnect with 2s backoff
- **Tests**: `node --test "cmd/odek/ui/js/**/*.test.js"` (golden tests for the markdown renderer and the untrusted-envelope parser)

## Tips

Expand Down
Loading