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
35 changes: 24 additions & 11 deletions cmd/odek/ui/js/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { S } from './state.js';
import { getLimits, getModels } from './api.js';
import { formatNum } from './utils.js';
import { escapeHtml, escapeAttr } from './escape.js';

// Metrics state (kept on S so the health popover can read it too).
S.metrics = {
Expand Down Expand Up @@ -243,27 +244,39 @@ export function resetMetrics() {
renderMetrics();
}

// turnStatsHTML is the per-message footer on done. Speed uses this-call
// fields only — never cumulative outputTokens / run latency.
export function turnStatsHTML(event) {
if (!event || event.latency == null) return '';
// turnStatsSpans is the per-message footer data on done: one entry per
// stat chip as {title, text}. Speed uses this-call fields only — never
// cumulative outputTokens / run latency. Returns plain data (no markup) so
// callers can render via textContent; every field is numeric-coerced before
// it leaves this function, so a hostile done frame can never smuggle markup.
export function turnStatsSpans(event) {
if (!event || event.latency == null) return [];
const lat = Number(event.latency);
const latSafe = Number.isFinite(lat) ? lat : 0;
const spans = [];
spans.push('<span title="Response time">⚡ ' + (latSafe < 1 ? (latSafe * 1000).toFixed(0) + 'ms' : latSafe.toFixed(1) + 's') + '</span>');
if (event.inputTokens != null) spans.push('<span title="Input tokens (run total, incl. sub-agents)">⌂ ' + formatNum(event.inputTokens) + '</span>');
if (event.outputTokens != null) spans.push('<span title="Output tokens (completion)">↳ ' + formatNum(event.outputTokens) + '</span>');
spans.push({ title: 'Response time', text: '⚡ ' + (latSafe < 1 ? (latSafe * 1000).toFixed(0) + 'ms' : latSafe.toFixed(1) + 's') });
if (event.inputTokens != null) spans.push({ title: 'Input tokens (run total, incl. sub-agents)', text: '⌂ ' + formatNum(event.inputTokens) });
if (event.outputTokens != null) spans.push({ title: 'Output tokens (completion)', text: '↳ ' + formatNum(event.outputTokens) });
const cache = (event.cacheReadTokens || 0) + (event.cacheCreationTokens || 0) + (event.cachedTokens || 0);
if (cache > 0) spans.push('<span title="Cached tokens">⛁ ' + formatNum(cache) + '</span>');
if (cache > 0) spans.push({ title: 'Cached tokens', text: '⛁ ' + formatNum(cache) });
const picked = pickTokPerSec(event);
if (picked.rate > 0) {
spans.push('<span title="' + tokPerSecTitle(picked.kind) + '">↗ ' + formatTokPerSec(picked.rate) + '</span>');
spans.push({ title: tokPerSecTitle(picked.kind), text: '↗ ' + formatTokPerSec(picked.rate) });
}
const turnCost = turnCostUSD(event.inputTokens || 0, event.outputTokens || 0);
if (turnCost != null && turnCost > 0) {
spans.push('<span title="Estimated cost of this turn at current prices">$ ' + turnCost.toFixed(4) + '</span>');
spans.push({ title: 'Estimated cost of this turn at current prices', text: '$ ' + turnCost.toFixed(4) });
}
return spans.join(' · ');
return spans;
}

// turnStatsHTML renders the same spans as an HTML string. Kept for callers
// that still need markup; both the title attribute and the body are escaped
// so even a future non-numeric span value cannot open an injection channel.
export function turnStatsHTML(event) {
return turnStatsSpans(event).map(sp =>
'<span title="' + escapeAttr(sp.title) + '">' + escapeHtml(sp.text) + '</span>'
).join(' · ');
}

// sessionCostUSD estimates the current session's spend from its totals.
Expand Down
41 changes: 40 additions & 1 deletion cmd/odek/ui/js/metrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ globalThis.localStorage = (() => {
})();

const { S } = await import('./state.js');
const { formatUSD, sessionCostUSD, renderMetrics, metricsDone, resetMetrics, pickTokPerSec, formatTokPerSec, metricsApplySpeed, metricsResetSpeed, turnStatsHTML, metricsBeginTurn, metricsLiveUsage, metricsLiveContext, metricsNoteOutput } = await import('./metrics.js');
const { formatUSD, sessionCostUSD, renderMetrics, metricsDone, resetMetrics, pickTokPerSec, formatTokPerSec, metricsApplySpeed, metricsResetSpeed, turnStatsHTML, turnStatsSpans, metricsBeginTurn, metricsLiveUsage, metricsLiveContext, metricsNoteOutput } = await import('./metrics.js');

beforeEach(() => {
S.metrics.pricesConfigured = false;
Expand Down Expand Up @@ -181,6 +181,45 @@ test('turnStatsHTML includes tok/s from this-call fields, never invented from to
assert.equal(none.includes('tok/s'), false);
});

// Pins the injection resistance of the done-frame stats footer: every span
// value must be numeric-coerced (turnStatsSpans) or escaped (turnStatsHTML)
// so a hostile done frame can never smuggle markup into the transcript.
const HOSTILE = '<img src=x onerror="alert(1)">';

test('turnStatsSpans coerces hostile done-frame values to inert numbers', () => {
const spans = turnStatsSpans({
latency: HOSTILE, inputTokens: HOSTILE, outputTokens: HOSTILE,
cacheReadTokens: HOSTILE, generationTokensPerSecond: HOSTILE,
});
assert.ok(spans.length > 0);
for (const sp of spans) {
assert.equal(sp.text.includes('<'), false, `markup leaked in text: ${sp.text}`);
assert.equal(sp.title.includes('<'), false, `markup leaked in title: ${sp.title}`);
}
// Coerced latency is finite → shows as 0ms, never the raw string.
assert.match(spans[0].text, /^⚡ 0ms$/);
});

test('turnStatsHTML escapes span titles and text against attribute/body injection', () => {
const html = turnStatsHTML({ latency: 1.2, inputTokens: 5 });
assert.doesNotMatch(html, /onerror|<img/i);
// No raw quote ever reaches the title attribute position.
const hostile = turnStatsSpans({ latency: HOSTILE });
const htmlHostile = turnStatsHTML({ latency: HOSTILE });
assert.equal(htmlHostile.includes(HOSTILE), false);
assert.equal(hostile.length > 0, true);
});

test('turnStatsHTML renders identical content to turnStatsSpans for normal events', () => {
const ev = { latency: 2.4, inputTokens: 1200, outputTokens: 300, generationTokensPerSecond: 30 };
const spans = turnStatsSpans(ev);
const html = turnStatsHTML(ev);
for (const sp of spans) {
assert.ok(html.includes(sp.text), `missing span text: ${sp.text}`);
}
assert.equal(html.split('<span').length - 1, spans.length);
});

test('usage overlays run-cumulative tokens onto session totals mid-turn', () => {
S.metrics.pricesConfigured = true;
S.metrics.inPrice = 1;
Expand Down
19 changes: 14 additions & 5 deletions cmd/odek/ui/js/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { queueApproval, dismissApproval, clearApprovals, expireApproval } from '
import { queueClarify, dismissClarify, clearClarify, expireClarify } from './clarify.js';
import { loadSessions } from './sessions.js';
import { onPong, onServerInfo, startHeartbeat, stopHeartbeat, notifyUser } from './health.js';
import { metricsLiveContext, metricsDone, metricsApplySpeed, metricsResetSpeed, metricsBeginTurn, metricsLiveUsage, turnStatsHTML, setMetricsModel } from './metrics.js';
import { metricsLiveContext, metricsDone, metricsApplySpeed, metricsResetSpeed, metricsBeginTurn, metricsLiveUsage, turnStatsSpans, setMetricsModel } from './metrics.js';
import { drainQueue } from './input.js';
import { setIntent, openTurn, markWakeTurn, sealTurn, paintIntent } from './render.js';
import { badgeNow } from './panels.js';
Expand Down Expand Up @@ -367,14 +367,23 @@ export function connect() {
badgeNow();
announce('Turn complete');
drainQueue();
// Append per-message stats to the last assistant bubble
const statsHTML = turnStatsHTML(event);
if (statsHTML) {
// Append per-message stats to the last assistant bubble. Built via
// textContent/setAttribute (never innerHTML) so server-controlled
// values cannot be reinterpreted as markup even if a future field
// loses its numeric coercion upstream.
const statsSpans = turnStatsSpans(event);
if (statsSpans.length) {
const lastAssistant = lastAssistantBubble();
if (lastAssistant) {
const stats = document.createElement('div');
stats.className = 'msg-stats';
stats.innerHTML = statsHTML;
statsSpans.forEach((sp, i) => {
if (i > 0) stats.appendChild(document.createTextNode(' · '));
const span = document.createElement('span');
span.title = sp.title;
span.textContent = sp.text;
stats.appendChild(span);
});
lastAssistant.appendChild(stats);
}
}
Expand Down
Loading