Implements VT-001 through VT-010 from the 2026-07-10 external security audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment): - VT-001 (Critical): AST hardening in backtest/runner.py rejects network/subprocess/eval/os.environ/unsafe-open calls reachable from generated SignalEngine method bodies (previously only checked top-level statements). Defense-in-depth: ephemeral per-run HOME, RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox user (currently inert under Docker's cap model - see session doc; accepted as documented residual gap, primary AST defense unaffected). - VT-002: multi-stage Dockerfile split so build-essential never reaches the runtime image. - VT-003: SSE auth moved from a long-lived API key in the URL query string to short-lived single-use tickets; access-log redaction for any remaining query secrets. - VT-004: agent/.env split-brain fixed (bind mount + atomic 0600 writes) between docker-compose and Web UI settings writes. - VT-005: /correlation now requires auth + per-IP rate limit; generic exceptions no longer leak raw text to clients. - VT-006: CSP-Report-Only + security headers middleware; Google Fonts replaced with self-hosted @fontsource packages. - VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add, no-new-privileges, read_only rootfs, resource limits). - VT-008: /health split into /live (liveness) and /ready (real provider-config readiness check); /health kept as a compat alias. - VT-009: base images pinned by digest, GitHub Actions pinned by commit SHA, dependabot.yml added, hash-verified requirements-lock.txt now actually wired into the Docker build via --require-hashes. - VT-010: universe-panel pickle cache sidecar upgraded from an unkeyed SHA-256 (forgeable by anyone with cache-dir write access) to a keyed HMAC. Implemented via a 5-parcel Workflow (file-disjoint, independent adversarial verification per parcel, then a holistic final audit). The final audit caught a real regression (CI env-var-gate violation) and confirmed a dependency-pinning gap, both fixed and re-verified here, including a latent transitive-dependency bug the fix surfaced (smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see docs/sessions/2026-07-13_session_vt476_security_hardening.md (local, gitignored) for full evidence trail. Full suite: 5191 passed, 6 pre-existing/environmental failures (unchanged from main). Docker build + hardened compose boot verified end-to-end.
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
/**
|
|
* SSE Hook — auto-reconnect + exponential backoff + LRU dedup + Last-Event-ID resume.
|
|
*/
|
|
|
|
import { useCallback, useRef } from "react";
|
|
import { getApiAuthKey, withAuthTicket } from "@/lib/apiAuth";
|
|
|
|
type EventHandler = (data: Record<string, unknown>) => void;
|
|
type Handlers = Record<string, EventHandler>;
|
|
|
|
export type SSEStatus = "disconnected" | "connected" | "reconnecting";
|
|
|
|
interface SSEConfig {
|
|
initialRetryMs?: number;
|
|
maxRetryMs?: number;
|
|
backoffFactor?: number;
|
|
dedupeCapacity?: number;
|
|
}
|
|
|
|
const DEFAULTS: Required<SSEConfig> = {
|
|
initialRetryMs: 1000,
|
|
maxRetryMs: 30000,
|
|
backoffFactor: 2,
|
|
dedupeCapacity: 500,
|
|
};
|
|
|
|
export function useSSE(config?: SSEConfig) {
|
|
const opts = { ...DEFAULTS, ...config };
|
|
const sourceRef = useRef<EventSource | null>(null);
|
|
const handlersRef = useRef<Handlers>({});
|
|
const urlRef = useRef<string>("");
|
|
const closedRef = useRef(true);
|
|
const retryCountRef = useRef(0);
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastEventIdRef = useRef<string | null>(null);
|
|
const statusRef = useRef<SSEStatus>("disconnected");
|
|
const onStatusChangeRef = useRef<((s: SSEStatus) => void) | null>(null);
|
|
|
|
// LRU dedup set
|
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
|
const seenOrderRef = useRef<string[]>([]);
|
|
|
|
const trackEventId = useCallback((eventId: string): boolean => {
|
|
if (!eventId) return false;
|
|
const seen = seenIdsRef.current;
|
|
const order = seenOrderRef.current;
|
|
if (seen.has(eventId)) return true; // duplicate
|
|
seen.add(eventId);
|
|
order.push(eventId);
|
|
if (order.length > opts.dedupeCapacity) {
|
|
const oldest = order.shift()!;
|
|
seen.delete(oldest);
|
|
}
|
|
return false;
|
|
}, [opts.dedupeCapacity]);
|
|
|
|
const setStatus = useCallback((s: SSEStatus) => {
|
|
statusRef.current = s;
|
|
onStatusChangeRef.current?.(s);
|
|
}, []);
|
|
|
|
const buildUrl = useCallback((baseUrl: string) => {
|
|
const sep = baseUrl.includes("?") ? "&" : "?";
|
|
if (lastEventIdRef.current) {
|
|
return `${baseUrl}${sep}Last-Event-ID=${encodeURIComponent(lastEventIdRef.current)}`;
|
|
}
|
|
return baseUrl;
|
|
}, []);
|
|
|
|
const attach = useCallback((url: string) => {
|
|
if (closedRef.current) return;
|
|
|
|
const source = new EventSource(url);
|
|
sourceRef.current = source;
|
|
|
|
source.onopen = () => {
|
|
retryCountRef.current = 0;
|
|
setStatus("connected");
|
|
};
|
|
|
|
// Only subscribe to event types the backend actually emits
|
|
const knownTypes = [
|
|
"text_delta", "reasoning_delta", "stream_reset", "thinking_done", "tool_call", "tool_result", "compact",
|
|
"tool_heartbeat", "tool_progress", "llm_usage",
|
|
"swarm.started", "swarm.event",
|
|
"attempt.created", "attempt.started", "attempt.completed", "attempt.failed",
|
|
"message.received", "session.created",
|
|
"goal.created", "goal.evidence", "goal.updated",
|
|
"mandate.proposal", "mandate.committed", "live.halted", "live.resumed", "live.action",
|
|
"heartbeat", "done",
|
|
];
|
|
|
|
const handleRaw = (eventType: string, raw: MessageEvent) => {
|
|
if (raw.lastEventId) {
|
|
lastEventIdRef.current = raw.lastEventId;
|
|
}
|
|
if (raw.lastEventId && trackEventId(raw.lastEventId)) return;
|
|
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(raw.data);
|
|
} catch {
|
|
parsed = { raw: raw.data };
|
|
}
|
|
|
|
const handler = handlersRef.current[eventType] ?? handlersRef.current["message"];
|
|
handler?.(parsed);
|
|
};
|
|
|
|
for (const eventType of knownTypes) {
|
|
source.addEventListener(eventType, (e) => handleRaw(eventType, e as MessageEvent));
|
|
}
|
|
|
|
source.onerror = () => {
|
|
if (closedRef.current) return;
|
|
source.close();
|
|
sourceRef.current = null;
|
|
scheduleReconnect();
|
|
};
|
|
}, [trackEventId, setStatus]);
|
|
|
|
const doConnect = useCallback(() => {
|
|
if (closedRef.current) return;
|
|
|
|
const baseUrl = buildUrl(urlRef.current);
|
|
|
|
// When an API key is stored we must first mint a single-use SSE ticket —
|
|
// EventSource can't send an Authorization header. In loopback dev mode (no
|
|
// key) the backend bypasses auth, so we connect synchronously and preserve
|
|
// the original zero-round-trip behavior (and the synchronous test path).
|
|
if (!getApiAuthKey()) {
|
|
attach(baseUrl);
|
|
return;
|
|
}
|
|
withAuthTicket(baseUrl)
|
|
.then(attach)
|
|
.catch(() => {
|
|
if (!closedRef.current) scheduleReconnect();
|
|
});
|
|
}, [buildUrl, attach]);
|
|
|
|
const scheduleReconnect = useCallback(() => {
|
|
if (closedRef.current) return;
|
|
retryCountRef.current += 1;
|
|
const delay = Math.min(
|
|
opts.initialRetryMs * Math.pow(opts.backoffFactor, retryCountRef.current - 1),
|
|
opts.maxRetryMs,
|
|
);
|
|
setStatus("reconnecting");
|
|
handlersRef.current["reconnect"]?.({ attempt: retryCountRef.current, delayMs: delay });
|
|
|
|
retryTimerRef.current = setTimeout(() => {
|
|
retryTimerRef.current = null;
|
|
doConnect();
|
|
}, delay);
|
|
}, [opts.initialRetryMs, opts.backoffFactor, opts.maxRetryMs, setStatus, doConnect]);
|
|
|
|
const connect = useCallback((url: string, handlers: Handlers) => {
|
|
closedRef.current = true;
|
|
sourceRef.current?.close();
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
|
|
urlRef.current = url;
|
|
handlersRef.current = handlers;
|
|
closedRef.current = false;
|
|
retryCountRef.current = 0;
|
|
lastEventIdRef.current = null;
|
|
seenIdsRef.current.clear();
|
|
seenOrderRef.current.length = 0;
|
|
|
|
doConnect();
|
|
}, [doConnect]);
|
|
|
|
const disconnect = useCallback(() => {
|
|
closedRef.current = true;
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
sourceRef.current?.close();
|
|
sourceRef.current = null;
|
|
setStatus("disconnected");
|
|
}, [setStatus]);
|
|
|
|
const getStatus = useCallback(() => statusRef.current, []);
|
|
|
|
const onStatusChange = useCallback((cb: (s: SSEStatus) => void) => {
|
|
onStatusChangeRef.current = cb;
|
|
}, []);
|
|
|
|
return { connect, disconnect, getStatus, onStatusChange };
|
|
}
|