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
4 changes: 3 additions & 1 deletion dashboard/src/api/resilient-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export class ResilientWebSocket {
this.ws.onmessage = (event: MessageEvent) => {
if (this.destroyed) return;
try {
this.callbacks.onMessage(JSON.parse(event.data as string));
const raw = JSON.parse(event.data as string);
// Pass raw parsed data to callback; callback is responsible for validation
this.callbacks.onMessage(raw);
} catch {
// ignore malformed messages
}
Expand Down
23 changes: 23 additions & 0 deletions dashboard/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,26 @@ export const GlobalSSEEventSchema: z.ZodType<GlobalSSEEvent> = z.object({
id: z.number().optional(),
data: z.record(z.string(), z.unknown()),
});

// ── WebSocket Terminal Messages (Issue #1107) ─────────────────────

const WsPaneMessageSchema = z.object({
type: z.literal('pane'),
content: z.string(),
});

const WsStatusMessageSchema = z.object({
type: z.literal('status'),
status: z.string(),
});

const WsErrorMessageSchema = z.object({
type: z.literal('error'),
message: z.string(),
});

export const WsInboundMessageSchema = z.discriminatedUnion('type', [
WsPaneMessageSchema,
WsStatusMessageSchema,
WsErrorMessageSchema,
]);
21 changes: 16 additions & 5 deletions dashboard/src/components/session/TerminalPassthrough.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useStore } from '../../store/useStore';
import type { AppState } from '../../store/useStore';
import { useToastStore } from '../../store/useToastStore';
import { getSessionMessages, subscribeSSE } from '../../api/client';
import type { ParsedEntry, WsInboundMessage, UIState } from '../../types';
import type { ParsedEntry, UIState } from '../../types';
import { SessionSSEEventDataSchema, WsInboundMessageSchema } from '../../api/schemas';

interface TerminalPassthroughProps {
sessionId: string;
Expand Down Expand Up @@ -120,9 +121,14 @@ export function TerminalPassthrough({ sessionId, status }: TerminalPassthroughPr
useEffect(() => {
const unsubscribe = subscribeSSE(sessionId, (e) => {
try {
const raw = JSON.parse(e.data as string);
if (raw.event !== 'message') return;
const data: ParsedEntry = raw.data;
const result = SessionSSEEventDataSchema.safeParse(JSON.parse(e.data as string));
if (!result.success) {
console.warn('SSE event failed validation', result.error.message);
return;
}
const parsed = result.data;
if (parsed.event !== 'message') return;
const data = parsed.data as unknown as ParsedEntry;
setMessages(prev => {
const key = dedupKey(data);
if (seenKeys.current.has(key)) return prev;
Expand Down Expand Up @@ -252,7 +258,12 @@ export function TerminalPassthrough({ sessionId, status }: TerminalPassthroughPr
const url = getWsUrl();
const ws = new ResilientWebSocket(url, {
onMessage: (data: unknown) => {
const msg = data as WsInboundMessage;
const result = WsInboundMessageSchema.safeParse(data);
if (!result.success) {
console.warn('WebSocket message failed validation', result.error.message);
return;
}
const msg = result.data;
const term = xtermRef.current;
if (!term) return;

Expand Down
12 changes: 9 additions & 3 deletions dashboard/src/components/session/TranscriptViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ParsedEntry } from '../../types';
import { getSessionMessages, subscribeSSE } from '../../api/client';
import { useStore } from '../../store/useStore';
import { MessageBubble } from './MessageBubble';
import { SessionSSEEventDataSchema } from '../../api/schemas';

const MAX_SESSION_MESSAGES = 1000;

Expand Down Expand Up @@ -66,11 +67,16 @@ export function TranscriptViewer({ sessionId }: TranscriptViewerProps) {
useEffect(() => {
const unsubscribe = subscribeSSE(sessionId, (e) => {
try {
const raw = JSON.parse(e.data as string);
const result = SessionSSEEventDataSchema.safeParse(JSON.parse(e.data as string));
if (!result.success) {
console.warn('SSE event failed validation', result.error.message);
return;
}
const parsed = result.data;
// Issue #261: Only process message events; skip status, heartbeat,
// approval, stall, dead, ended, hook, and subagent events.
if (raw.event !== 'message') return;
const data: ParsedEntry = raw.data;
if (parsed.event !== 'message') return;
const data = parsed.data as unknown as ParsedEntry;
setMessages(prev => {
const key = dedupKey(data);
if (seenKeys.current.has(key)) return prev;
Expand Down
Loading