From 5015a0909dc40acb7aed4422c9cbd9b7634bde70 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 21 Jun 2026 14:14:02 +0200 Subject: [PATCH 1/2] feat(dashboard): define trace session read model --- .../__fixtures__/trace-session-read-model.ts | 42 +++++- .../src/lib/trace-read-model.test.ts | 35 ++++- apps/dashboard/src/lib/trace-read-model.ts | 136 ++++++++++++++++-- apps/dashboard/src/lib/types.ts | 33 ++++- 4 files changed, 231 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts b/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts index 631549774..191219355 100644 --- a/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts +++ b/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts @@ -99,16 +99,56 @@ export const traceSessionEnvelopeFixture = { metadata: { external_trace: { provider: 'phoenix', + source: 'codex', project: 'agentv-dogfood', session_id: 'codex-session-123', trace_id: 'phoenix-trace-456', - url: 'https://phoenix.example/projects/agentv-dogfood/traces/phoenix-trace-456?api_key=secret', + ui_url: + 'https://phoenix.example/projects/agentv-dogfood/traces/phoenix-trace-456?api_key=secret', + run_id: '2026-06-21T10-00-00-000Z', + test_id: 'nested-session', + target: 'codex', api_key: 'secret', }, safe_note: 'local artifact remains canonical', access_token: 'secret', }, }, + artifacts: { + trace_path: 'outputs/trace.json', + answer_path: 'outputs/answer.md', + transcript_path: 'outputs/transcript.jsonl', + secret_token_path: 'outputs/secret-token.txt', + unsafe_url_path: 'https://phoenix.example/artifacts/trace.json?api_key=secret', + traversal_path: '../outside/trace.json', + }, + conversion_warnings: [ + { + code: 'missing_event_time', + severity: 'warning', + span_id: 'child-chat', + source_ref: { + event_id: 'raw-event-1', + span_id: 'child-chat', + trace_id: 'trace-123', + raw_kind: 'codex_event', + path: 'outputs/raw/events.jsonl', + line: 7, + metadata: { + safe_value: 'visible', + authorization: 'Bearer secret', + }, + }, + message: 'Converted event did not include a timestamp.', + details: { + raw_kind: 'codex_event', + nested: { + safe_value: 'visible', + refresh_token: 'secret', + }, + }, + }, + ], scores: [ { name: 'rubric', diff --git a/apps/dashboard/src/lib/trace-read-model.test.ts b/apps/dashboard/src/lib/trace-read-model.test.ts index 68127da36..a6be11f8c 100644 --- a/apps/dashboard/src/lib/trace-read-model.test.ts +++ b/apps/dashboard/src/lib/trace-read-model.test.ts @@ -49,6 +49,11 @@ describe('trace session read model', () => { source: { artifact_path: 'nested-session__codex/outputs/trace.json', }, + artifact_links: [ + { name: 'answer_path', path: 'outputs/answer.md' }, + { name: 'trace_path', path: 'outputs/trace.json' }, + { name: 'transcript_path', path: 'outputs/transcript.jsonl' }, + ], }); expect(session.spans.map((span) => span.id)).toEqual([ 'root-span', @@ -107,6 +112,27 @@ describe('trace session read model', () => { }, }, ]); + expect(session.conversion_warnings).toEqual([ + { + code: 'missing_event_time', + severity: 'warning', + span_id: 'child-chat', + source_ref: { + event_id: 'raw-event-1', + span_id: 'child-chat', + trace_id: 'trace-123', + raw_kind: 'codex_event', + path: 'outputs/raw/events.jsonl', + line: 7, + metadata: { safe_value: 'visible' }, + }, + message: 'Converted event did not include a timestamp.', + details: { + raw_kind: 'codex_event', + nested: { safe_value: 'visible' }, + }, + }, + ]); }); it('keeps external_trace links safe and leaves AgentV as canonical source', () => { @@ -114,15 +140,22 @@ describe('trace session read model', () => { expect(session.external_trace).toEqual({ provider: 'phoenix', + source: 'codex', project: 'agentv-dogfood', session_id: 'codex-session-123', trace_id: 'phoenix-trace-456', - url: 'https://phoenix.example/projects/agentv-dogfood/traces/phoenix-trace-456', + ui_url: 'https://phoenix.example/projects/agentv-dogfood/traces/phoenix-trace-456', + run_id: '2026-06-21T10-00-00-000Z', + test_id: 'nested-session', + target: 'codex', }); expect(JSON.stringify(session.external_trace)).not.toContain('secret'); expect(JSON.stringify(session.external_trace)).not.toContain('api_key'); expect(JSON.stringify(session)).not.toContain('secret'); expect(JSON.stringify(session)).not.toContain('api_key'); + expect(JSON.stringify(session.artifact_links)).not.toContain('unsafe_url_path'); + expect(JSON.stringify(session.artifact_links)).not.toContain('traversal_path'); + expect(JSON.stringify(session.artifact_links)).not.toContain('secret_token_path'); expect(session.source?.metadata).toEqual({ safe_note: 'local artifact remains canonical', }); diff --git a/apps/dashboard/src/lib/trace-read-model.ts b/apps/dashboard/src/lib/trace-read-model.ts index b14fa4c2b..ac353c42d 100644 --- a/apps/dashboard/src/lib/trace-read-model.ts +++ b/apps/dashboard/src/lib/trace-read-model.ts @@ -1,10 +1,13 @@ import type { ExternalTraceMetadata, + TraceSessionArtifactLink, + TraceSessionConversionWarning, TraceSessionEvent, TraceSessionEventKind, TraceSessionResponse, TraceSessionScore, TraceSessionSource, + TraceSessionSourceRef, TraceSessionSpan, TraceSessionTokenUsage, } from './types'; @@ -60,6 +63,10 @@ function finiteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function finiteInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined; +} + function boolValue(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined; } @@ -376,11 +383,17 @@ function projectScores(scores: unknown): TraceSessionScore[] | undefined { return projected.length > 0 ? projected : undefined; } -const EXTERNAL_TRACE_KEYS = ['provider', 'project', 'session_id', 'trace_id', 'url'] as const; - -function isSecretLikeKey(key: string): boolean { - return /(api[_-]?key|authorization|bearer|password|secret|token)/i.test(key); -} +const EXTERNAL_TRACE_KEYS = [ + 'provider', + 'source', + 'project', + 'session_id', + 'trace_id', + 'ui_url', + 'run_id', + 'test_id', + 'target', +] as const; function sanitizeUrl(value: unknown): string | undefined { const raw = stringValue(value); @@ -408,10 +421,14 @@ function sanitizeExternalTrace(value: unknown): ExternalTraceMetadata | undefine const sanitized = compactRecord({ provider: stringValue(record.provider), + source: stringValue(record.source), project: stringValue(record.project), - session_id: stringValue(record.session_id), - trace_id: stringValue(record.trace_id), - url: sanitizeUrl(record.url), + session_id: stringValue(record.session_id) ?? stringValue(record.session), + trace_id: stringValue(record.trace_id) ?? stringValue(record.trace), + ui_url: sanitizeUrl(record.ui_url ?? record.url ?? record.href), + run_id: stringValue(record.run_id), + test_id: stringValue(record.test_id), + target: stringValue(record.target), }) as ExternalTraceMetadata | undefined; return sanitized && EXTERNAL_TRACE_KEYS.some((key) => sanitized[key] !== undefined) @@ -427,10 +444,26 @@ function externalTraceFromFlatMetadata( } return sanitizeExternalTrace({ provider: metadata.external_trace_provider ?? metadata['external_trace.provider'], + source: metadata.external_trace_source ?? metadata['external_trace.source'], project: metadata.external_trace_project ?? metadata['external_trace.project'], - session_id: metadata.external_trace_session_id ?? metadata['external_trace.session_id'], - trace_id: metadata.external_trace_trace_id ?? metadata['external_trace.trace_id'], - url: metadata.external_trace_url ?? metadata['external_trace.url'], + session_id: + metadata.external_trace_session_id ?? + metadata.external_trace_session ?? + metadata['external_trace.session_id'] ?? + metadata['external_trace.session'], + trace_id: + metadata.external_trace_trace_id ?? + metadata.external_trace_trace ?? + metadata['external_trace.trace_id'] ?? + metadata['external_trace.trace'], + ui_url: + metadata.external_trace_ui_url ?? + metadata.external_trace_url ?? + metadata['external_trace.ui_url'] ?? + metadata['external_trace.url'], + run_id: metadata.external_trace_run_id ?? metadata['external_trace.run_id'], + test_id: metadata.external_trace_test_id ?? metadata['external_trace.test_id'], + target: metadata.external_trace_target ?? metadata['external_trace.target'], }); } @@ -441,7 +474,7 @@ function sanitizeMetadata( return undefined; } const entries = Object.entries(value).flatMap(([key, entry]) => { - if (isExternalTraceKey(key) || isSecretLikeKey(key)) { + if (isExternalTraceKey(key) || isCredentialLikeKey(key)) { return []; } if (isRecord(entry)) { @@ -471,6 +504,83 @@ function sourceFromEnvelope( }) as TraceSessionSource | undefined; } +function safeArtifactPath(value: unknown): string | undefined { + const raw = stringValue(value); + if (!raw || raw.includes('\0') || raw.startsWith('/')) { + return undefined; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) { + return undefined; + } + + const normalized = raw.replace(/\\/g, '/'); + if (normalized.split('/').includes('..')) { + return undefined; + } + return normalized; +} + +function projectArtifactLinks(artifacts: unknown): TraceSessionArtifactLink[] | undefined { + const record = asRecord(artifacts); + if (!record) { + return undefined; + } + + const links = Object.entries(record) + .flatMap(([name, value]) => { + if (!stringValue(name) || isCredentialLikeKey(name)) { + return []; + } + const artifactPath = safeArtifactPath(value); + return artifactPath ? [{ name, path: artifactPath }] : []; + }) + .sort((first, second) => first.name.localeCompare(second.name)); + + return links.length > 0 ? links : undefined; +} + +function projectSourceRef(value: unknown): TraceSessionSourceRef | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return compactRecord({ + event_id: stringValue(record.event_id), + message_id: stringValue(record.message_id), + span_id: stringValue(record.span_id), + trace_id: stringValue(record.trace_id), + raw_kind: stringValue(record.raw_kind), + path: safeArtifactPath(record.path), + line: finiteInteger(record.line), + metadata: sanitizeMetadata(asRecord(record.metadata)), + }) as TraceSessionSourceRef | undefined; +} + +function projectConversionWarnings(warnings: unknown): TraceSessionConversionWarning[] | undefined { + const projected: TraceSessionConversionWarning[] = []; + + for (const warning of asArray(warnings)) { + const record = asRecord(warning); + const code = stringValue(record?.code); + const message = stringValue(record?.message); + if (!record || !code || !message) { + continue; + } + projected.push( + dropUndefined({ + code, + severity: stringValue(record.severity), + span_id: stringValue(record.span_id), + source_ref: projectSourceRef(record.source_ref), + message, + details: sanitizeMetadata(asRecord(record.details)), + }) as TraceSessionConversionWarning, + ); + } + + return projected.length > 0 ? projected : undefined; +} + function externalTraceFromEnvelope( envelope: Record, ): ExternalTraceMetadata | undefined { @@ -515,6 +625,8 @@ export function traceEnvelopeToTraceSessionResponse( root_span_id: stringValue(trace?.root_span_id), source: sourceFromEnvelope(asRecord(envelope.source), options.artifactPath), external_trace: externalTraceFromEnvelope(envelope), + artifact_links: projectArtifactLinks(envelope.artifacts), + conversion_warnings: projectConversionWarnings(envelope.conversion_warnings), spans, events, scores: projectScores(envelope.scores), diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index a3d7d20b3..55781a900 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -141,10 +141,14 @@ export interface ExternalTraceMetadata { * canonical source of truth for Dashboard trace/session details. */ provider?: string; + source?: string; project?: string; session_id?: string; trace_id?: string; - url?: string; + ui_url?: string; + run_id?: string; + test_id?: string; + target?: string; } export interface TraceSessionTokenUsage { @@ -215,6 +219,31 @@ export interface TraceSessionSource { metadata?: Record; } +export interface TraceSessionArtifactLink { + name: string; + path: string; +} + +export interface TraceSessionSourceRef { + event_id?: string; + message_id?: string; + span_id?: string; + trace_id?: string; + raw_kind?: string; + path?: string; + line?: number; + metadata?: Record; +} + +export interface TraceSessionConversionWarning { + code: string; + severity?: 'info' | 'warning' | 'error' | string; + span_id?: string; + source_ref?: TraceSessionSourceRef; + message: string; + details?: Record; +} + export interface TraceSessionResponse { schema_version: 'agentv.dashboard.trace_session.v1'; artifact_id?: string; @@ -227,6 +256,8 @@ export interface TraceSessionResponse { root_span_id?: string; source?: TraceSessionSource; external_trace?: ExternalTraceMetadata; + artifact_links?: TraceSessionArtifactLink[]; + conversion_warnings?: TraceSessionConversionWarning[]; spans: TraceSessionSpan[]; events: TraceSessionEvent[]; scores?: TraceSessionScore[]; From ec5c0ed9179d2111df2efced2f496b2f107652f3 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 21 Jun 2026 14:51:30 +0200 Subject: [PATCH 2/2] fix(dashboard): sanitize trace warning metadata --- .../__fixtures__/trace-session-read-model.ts | 31 +++++++++++++++++++ .../src/lib/trace-read-model.test.ts | 24 +++++++++++++- apps/dashboard/src/lib/trace-read-model.ts | 31 +++++++++++++------ 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts b/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts index 191219355..ad501dbff 100644 --- a/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts +++ b/apps/dashboard/src/lib/__fixtures__/trace-session-read-model.ts @@ -121,6 +121,7 @@ export const traceSessionEnvelopeFixture = { secret_token_path: 'outputs/secret-token.txt', unsafe_url_path: 'https://phoenix.example/artifacts/trace.json?api_key=secret', traversal_path: '../outside/trace.json', + unc_path: '\\\\evil.example\\share\\trace.json', }, conversion_warnings: [ { @@ -137,6 +138,13 @@ export const traceSessionEnvelopeFixture = { metadata: { safe_value: 'visible', authorization: 'Bearer secret', + messages: [ + { authorization: 'Bearer nested secret' }, + { + safe_value: 'visible', + nested: { keep: 'yes', refresh_token: 'secret' }, + }, + ], }, }, message: 'Converted event did not include a timestamp.', @@ -146,6 +154,29 @@ export const traceSessionEnvelopeFixture = { safe_value: 'visible', refresh_token: 'secret', }, + messages: [ + { authorization: 'Bearer detail secret' }, + { + safe_value: 'visible', + nested: { keep: 'yes', token: 'secret' }, + }, + ], + empty_messages: [{ authorization: 'Bearer only secret' }], + }, + }, + { + code: 'unsafe_source_ref_path', + severity: 'warning', + source_ref: { + span_id: 'grandchild-tool', + path: '\\\\evil.example\\share\\trace.json', + metadata: { + messages: [{ authorization: 'Bearer secret' }, { safe_value: 'visible' }], + }, + }, + message: 'External-looking source path omitted.', + details: { + messages: [{ authorization: 'Bearer secret' }, { safe_value: 'visible' }], }, }, ], diff --git a/apps/dashboard/src/lib/trace-read-model.test.ts b/apps/dashboard/src/lib/trace-read-model.test.ts index a6be11f8c..0bf2015f3 100644 --- a/apps/dashboard/src/lib/trace-read-model.test.ts +++ b/apps/dashboard/src/lib/trace-read-model.test.ts @@ -124,12 +124,28 @@ describe('trace session read model', () => { raw_kind: 'codex_event', path: 'outputs/raw/events.jsonl', line: 7, - metadata: { safe_value: 'visible' }, + metadata: { + safe_value: 'visible', + messages: [{ safe_value: 'visible', nested: { keep: 'yes' } }], + }, }, message: 'Converted event did not include a timestamp.', details: { raw_kind: 'codex_event', nested: { safe_value: 'visible' }, + messages: [{ safe_value: 'visible', nested: { keep: 'yes' } }], + }, + }, + { + code: 'unsafe_source_ref_path', + severity: 'warning', + source_ref: { + span_id: 'grandchild-tool', + metadata: { messages: [{ safe_value: 'visible' }] }, + }, + message: 'External-looking source path omitted.', + details: { + messages: [{ safe_value: 'visible' }], }, }, ]); @@ -156,6 +172,12 @@ describe('trace session read model', () => { expect(JSON.stringify(session.artifact_links)).not.toContain('unsafe_url_path'); expect(JSON.stringify(session.artifact_links)).not.toContain('traversal_path'); expect(JSON.stringify(session.artifact_links)).not.toContain('secret_token_path'); + expect(JSON.stringify(session.artifact_links)).not.toContain('unc_path'); + expect(JSON.stringify(session.artifact_links)).not.toContain('evil.example'); + expect(JSON.stringify(session.conversion_warnings)).not.toContain('authorization'); + expect(JSON.stringify(session.conversion_warnings)).not.toContain('Bearer'); + expect(JSON.stringify(session.conversion_warnings)).not.toContain('empty_messages'); + expect(JSON.stringify(session.conversion_warnings)).not.toContain('evil.example'); expect(session.source?.metadata).toEqual({ safe_note: 'local artifact remains canonical', }); diff --git a/apps/dashboard/src/lib/trace-read-model.ts b/apps/dashboard/src/lib/trace-read-model.ts index ac353c42d..9c8e0a5a3 100644 --- a/apps/dashboard/src/lib/trace-read-model.ts +++ b/apps/dashboard/src/lib/trace-read-model.ts @@ -467,6 +467,19 @@ function externalTraceFromFlatMetadata( }); } +function sanitizeMetadataValue(value: unknown): unknown | undefined { + if (Array.isArray(value)) { + const sanitized = value + .map(sanitizeMetadataValue) + .filter((entry): entry is unknown => entry !== undefined); + return sanitized.length > 0 ? sanitized : undefined; + } + if (isRecord(value)) { + return sanitizeMetadata(value); + } + return value; +} + function sanitizeMetadata( value: Record | undefined, ): Record | undefined { @@ -477,11 +490,8 @@ function sanitizeMetadata( if (isExternalTraceKey(key) || isCredentialLikeKey(key)) { return []; } - if (isRecord(entry)) { - const nested = sanitizeMetadata(entry); - return nested ? [[key, nested] as const] : []; - } - return [[key, entry] as const]; + const sanitized = sanitizeMetadataValue(entry); + return sanitized !== undefined ? [[key, sanitized] as const] : []; }); return entries.length > 0 ? Object.fromEntries(entries) : undefined; } @@ -506,14 +516,17 @@ function sourceFromEnvelope( function safeArtifactPath(value: unknown): string | undefined { const raw = stringValue(value); - if (!raw || raw.includes('\0') || raw.startsWith('/')) { - return undefined; - } - if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) { + if (!raw || raw.includes('\0')) { return undefined; } const normalized = raw.replace(/\\/g, '/'); + if (normalized.startsWith('/') || normalized.startsWith('//')) { + return undefined; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(normalized)) { + return undefined; + } if (normalized.split('/').includes('..')) { return undefined; }