Skip to content

Commit 4880171

Browse files
anandgupta42claude
andauthored
fix: [AI-194] pre-release security and resource cleanup fixes for tracing (#197)
* fix: [AI-194] pre-release security and resource cleanup fixes for tracing - Escape `t.summary.status` with `e()` in trace viewer HTML to prevent XSS - Add SIGINT/SIGTERM handlers to gracefully stop trace viewer server on Ctrl+C - Log snapshot write failures with `console.debug` instead of silently swallowing Closes #194 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: comprehensive XSS hardening for trace viewer HTML Systematically escape all user-controllable fields in `viewer.ts`: - Escape `span.kind` and `span.status` in detail panel, waterfall, tree, and log views - Escape `span.spanId` in `data-sid` attributes - Coerce all numeric fields with `Number()` to prevent string injection via `.toLocaleString()` - Add single-quote escaping (`&#x27;`) to the `e()` function for defense-in-depth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: await `server.stop()` for graceful shutdown in trace viewer `Bun.Server.stop()` returns a `Promise<void>` — calling it without `await` exits the process before in-flight requests are drained. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: catch `server.stop()` rejection in shutdown handler Prevents unhandled promise rejection if the server fails to stop cleanly on SIGINT/SIGTERM. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f896814 commit 4880171

File tree

3 files changed

+34
-25
lines changed

3 files changed

+34
-25
lines changed

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,8 @@ export class Tracer {
627627
this.snapshotPromise = fs.mkdir(this.snapshotDir, { recursive: true })
628628
.then(() => fs.writeFile(tmpPath, JSON.stringify(trace, null, 2)))
629629
.then(() => fs.rename(tmpPath, filePath))
630-
.catch(() => {
630+
.catch((err) => {
631+
console.debug(`[tracing] failed to write trace snapshot: ${err}`)
631632
fs.unlink(tmpPath).catch(() => {})
632633
})
633634
.finally(() => {

packages/opencode/src/altimate/observability/viewer.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ pre.io { background: var(--bg); border: 1px solid var(--border); border-radius:
183183
184184
<script>
185185
var t = ${traceJSON};
186-
var e = function(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); };
186+
var e = function(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#x27;'); };
187187
var fd = function(ms) { if (!ms && ms !== 0) return '-'; ms = Math.abs(ms); if (ms < 1000) return ms + 'ms'; if (ms < 60000) return (ms/1000).toFixed(1) + 's'; return Math.floor(ms/60000) + 'm' + Math.floor((ms%60000)/1000) + 's'; };
188188
var fc = function(c) { if (c == null || isNaN(c)) return '$0'; return c < 0.01 ? '$' + c.toFixed(4) : '$' + c.toFixed(2); };
189189
var fb = function(b) { if (!b) return '0 B'; if (b < 1024) return b + ' B'; if (b < 1048576) return (b/1024).toFixed(1) + ' KB'; if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB'; return (b/1073741824).toFixed(2) + ' GB'; };
@@ -197,7 +197,7 @@ tagsHtml += '<span class="tag">Provider: <strong>' + e(t.metadata.providerId ||
197197
tagsHtml += '<span class="tag">Model: <strong>' + e(model) + '</strong></span>';
198198
tagsHtml += '<span class="tag">Agent: <strong>' + e(t.metadata.agent || 'default') + '</strong></span>';
199199
var stColor = t.summary.status === 'error' || t.summary.status === 'crashed' ? 'var(--red)' : t.summary.status === 'running' ? 'var(--orange)' : 'var(--green)';
200-
tagsHtml += '<span class="tag" style="border-color:' + stColor + '">Status: <strong style="color:' + stColor + '">' + (t.summary.status || 'unknown') + '</strong></span>';
200+
tagsHtml += '<span class="tag" style="border-color:' + stColor + '">Status: <strong style="color:' + stColor + '">' + e(t.summary.status || 'unknown') + '</strong></span>';
201201
${live ? "tagsHtml += '<span class=\"live-badge\"><span class=\"live-dot\"></span>LIVE</span>';" : ""}
202202
document.getElementById('tags').innerHTML = tagsHtml;
203203
@@ -209,11 +209,11 @@ if (t.metadata.prompt) {
209209
// --- Summary cards ---
210210
var s = t.summary || {}, tk = s.tokens || {};
211211
var cardsData = [
212-
['Duration', fd(s.duration), 'primary', true], ['Input', (tk.input||0).toLocaleString(), 'accent', (tk.input||0) > 0],
213-
['Output', (tk.output||0).toLocaleString(), 'accent', (tk.output||0) > 0], ['Cache', (tk.cacheRead||0).toLocaleString(), 'cyan', (tk.cacheRead||0) > 0],
214-
['Reasoning', (tk.reasoning||0).toLocaleString(), 'accent', (tk.reasoning||0) > 0], ['Total', (s.totalTokens||0).toLocaleString(), 'accent', true],
215-
['Cost', fc(s.totalCost), 'orange', true], ['Gens', s.totalGenerations||0, 'secondary', true],
216-
['Tools', s.totalToolCalls||0, 'green', true]
212+
['Duration', fd(s.duration), 'primary', true], ['Input', Number(tk.input||0).toLocaleString(), 'accent', Number(tk.input||0) > 0],
213+
['Output', Number(tk.output||0).toLocaleString(), 'accent', Number(tk.output||0) > 0], ['Cache', Number(tk.cacheRead||0).toLocaleString(), 'cyan', Number(tk.cacheRead||0) > 0],
214+
['Reasoning', Number(tk.reasoning||0).toLocaleString(), 'accent', Number(tk.reasoning||0) > 0], ['Total', Number(s.totalTokens||0).toLocaleString(), 'accent', true],
215+
['Cost', fc(s.totalCost), 'orange', true], ['Gens', Number(s.totalGenerations||0), 'secondary', true],
216+
['Tools', Number(s.totalToolCalls||0), 'green', true]
217217
];
218218
document.getElementById('cards').innerHTML = cardsData.filter(function(c) { return c[3]; }).map(function(c) {
219219
return '<div class="card"><div class="lbl">' + c[0] + '</div><div class="val" style="color:var(--' + c[2] + ')">' + c[1] + '</div></div>';
@@ -239,8 +239,8 @@ var icons = { session: '\\u25A0', generation: '\\u2B50', tool: '\\u2692', text:
239239
function showDetail(span) {
240240
var dur = (span.endTime || Date.now()) - (span.startTime || 0);
241241
var h = '<div class="detail-panel"><h3>' + e(span.name) + '</h3><dl class="dg">';
242-
h += '<dt>Kind</dt><dd>' + (span.kind||'') + '</dd>';
243-
h += '<dt>Status</dt><dd' + (span.status==='error'?' style="color:var(--red)"':'') + '>' + (span.status||'') + '</dd>';
242+
h += '<dt>Kind</dt><dd>' + e(span.kind||'') + '</dd>';
243+
h += '<dt>Status</dt><dd' + (span.status==='error'?' style="color:var(--red)"':'') + '>' + e(span.status||'') + '</dd>';
244244
if (span.statusMessage) h += '<dt>Error</dt><dd style="color:var(--red)">' + e(span.statusMessage) + '</dd>';
245245
h += '<dt>Duration</dt><dd>' + fd(dur) + '</dd>';
246246
if (span.model) {
@@ -251,12 +251,12 @@ function showDetail(span) {
251251
if (span.finishReason) h += '<dt>Finish Reason</dt><dd>' + e(span.finishReason) + '</dd>';
252252
if (span.cost != null) h += '<dt>Cost</dt><dd>' + fc(span.cost) + '</dd>';
253253
if (span.tokens) {
254-
h += '<dt>Input Tokens</dt><dd>' + (span.tokens.input||0).toLocaleString() + '</dd>';
255-
h += '<dt>Output Tokens</dt><dd>' + (span.tokens.output||0).toLocaleString() + '</dd>';
256-
if (span.tokens.reasoning) h += '<dt>Reasoning</dt><dd>' + span.tokens.reasoning.toLocaleString() + '</dd>';
257-
if (span.tokens.cacheRead) h += '<dt>Cache Read</dt><dd>' + span.tokens.cacheRead.toLocaleString() + '</dd>';
258-
if (span.tokens.cacheWrite) h += '<dt>Cache Write</dt><dd>' + span.tokens.cacheWrite.toLocaleString() + '</dd>';
259-
h += '<dt>Total</dt><dd>' + (span.tokens.total||0).toLocaleString() + '</dd>';
254+
h += '<dt>Input Tokens</dt><dd>' + Number(span.tokens.input||0).toLocaleString() + '</dd>';
255+
h += '<dt>Output Tokens</dt><dd>' + Number(span.tokens.output||0).toLocaleString() + '</dd>';
256+
if (span.tokens.reasoning) h += '<dt>Reasoning</dt><dd>' + Number(span.tokens.reasoning).toLocaleString() + '</dd>';
257+
if (span.tokens.cacheRead) h += '<dt>Cache Read</dt><dd>' + Number(span.tokens.cacheRead).toLocaleString() + '</dd>';
258+
if (span.tokens.cacheWrite) h += '<dt>Cache Write</dt><dd>' + Number(span.tokens.cacheWrite).toLocaleString() + '</dd>';
259+
h += '<dt>Total</dt><dd>' + Number(span.tokens.total||0).toLocaleString() + '</dd>';
260260
}
261261
if (span.tool) {
262262
if (span.tool.callId) h += '<dt>Call ID</dt><dd>' + e(span.tool.callId) + '</dd>';
@@ -309,10 +309,10 @@ function showDetail(span) {
309309
var dur = (span.endTime || Date.now()) - (span.startTime||0);
310310
var left = (st / tTotal * 100).toFixed(2);
311311
var width = Math.max(0.5, dur / tTotal * 100).toFixed(2);
312-
var cls = span.status === 'error' ? 'error' : span.kind;
312+
var cls = span.status === 'error' ? 'error' : e(span.kind);
313313
var row = document.createElement('div');
314314
row.className = 'wf-row';
315-
var iconCls = span.status === 'error' ? 'error' : span.kind;
315+
var iconCls = span.status === 'error' ? 'error' : e(span.kind);
316316
row.innerHTML = '<div class="wf-icon ' + iconCls + '">' + (icons[span.kind]||'\\u2022') + '</div>' +
317317
'<div class="wf-name">' + e(span.name) + '</div>' +
318318
'<div class="wf-bar-c"><div class="wf-bar ' + cls + '" style="left:'+left+'%;width:'+width+'%"><span class="wf-bar-label">' + fd(dur) + '</span></div></div>' +
@@ -338,12 +338,12 @@ function showDetail(span) {
338338
var dur = (span.endTime||Date.now()) - (span.startTime||0);
339339
var meta = [];
340340
meta.push(fd(dur));
341-
if (span.tokens) meta.push(span.tokens.total + ' tok');
341+
if (span.tokens) meta.push(Number(span.tokens.total||0) + ' tok');
342342
if (span.cost) meta.push(fc(span.cost));
343343
if (span.status === 'error') meta.push('<span style="color:var(--red)">error</span>');
344-
html += '<div class="tree-node"><div class="tree-item" data-sid="' + span.spanId + '">';
344+
html += '<div class="tree-node"><div class="tree-item" data-sid="' + e(span.spanId) + '">';
345345
html += '<div class="tree-head">';
346-
html += '<span class="tree-type ' + span.kind + '">' + span.kind + '</span>';
346+
html += '<span class="tree-type ' + e(span.kind) + '">' + e(span.kind) + '</span>';
347347
html += '<span class="tree-title">' + e(span.name) + '</span>';
348348
html += '</div>';
349349
html += '<div class="tree-meta">' + meta.join(' &middot; ') + '</div>';
@@ -400,7 +400,7 @@ function showDetail(span) {
400400
html += '<div class="chat-msg agent"><div class="chat-role">\\u2B50 ' + e(t.metadata.agent || 'Agent') + '</div>';
401401
html += '<div class="chat-bubble">' + e(String(gen.output)) + '</div>';
402402
var meta = [];
403-
if (gen.tokens) meta.push(gen.tokens.total + ' tokens');
403+
if (gen.tokens) meta.push(Number(gen.tokens.total||0) + ' tokens');
404404
if (gen.cost) meta.push(fc(gen.cost));
405405
meta.push(fd((gen.endTime||0)-(gen.startTime||0)));
406406
html += '<div style="font-size:11px;color:var(--dim);margin-top:4px">' + meta.join(' &middot; ') + '</div>';
@@ -420,13 +420,13 @@ function showDetail(span) {
420420
sorted.forEach(function(span) {
421421
if (span.kind === 'session') return;
422422
var ts = span.startTime ? new Date(span.startTime).toISOString().slice(11,23) : '';
423-
var kindCls = span.status === 'error' ? 'error' : span.kind;
423+
var kindCls = span.status === 'error' ? 'error' : e(span.kind);
424424
html += '<div class="log-entry">';
425425
html += '<span class="log-ts">' + ts + '</span>';
426426
var logIcon = span.kind === 'generation' ? '\\u2B50' : span.kind === 'tool' ? '\\u2692' : '\\u25A0';
427-
html += '<span class="log-kind ' + kindCls + '">' + logIcon + ' ' + (span.kind||'') + '</span>';
427+
html += '<span class="log-kind ' + kindCls + '">' + logIcon + ' ' + e(span.kind||'') + '</span>';
428428
html += '<span class="log-name">' + e(span.name) + '</span>';
429-
if (span.tokens) html += ' <span style="color:var(--dim);font-size:11px">' + span.tokens.total + ' tok</span>';
429+
if (span.tokens) html += ' <span style="color:var(--dim);font-size:11px">' + Number(span.tokens.total||0) + ' tok</span>';
430430
if (span.cost) html += ' <span style="color:var(--orange);font-size:11px">' + fc(span.cost) + '</span>';
431431
if (span.tool && span.tool.durationMs != null) html += ' <span style="color:var(--dim);font-size:11px">' + fd(span.tool.durationMs) + '</span>';
432432
if (span.status === 'error') html += ' <span style="color:var(--red);font-size:11px">\\u2718 ' + e((span.statusMessage||'').slice(0,100)) + '</span>';

packages/opencode/src/cli/cmd/trace.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ export const TraceCommand = cmd({
210210
// User can open manually
211211
}
212212

213+
// Graceful shutdown on interrupt
214+
const shutdown = async () => {
215+
try { await server.stop() } catch {}
216+
process.exit(0)
217+
}
218+
process.on("SIGINT", shutdown)
219+
process.on("SIGTERM", shutdown)
220+
213221
// Keep server alive until interrupted
214222
await new Promise(() => {})
215223
}

0 commit comments

Comments
 (0)