From 47f9f1c5812a46afc3e6fbfe1c6849d3dc22374e Mon Sep 17 00:00:00 2001 From: Jithin Date: Wed, 29 Jul 2026 12:54:55 +0530 Subject: [PATCH 1/4] feat: redesign hunt --ui dashboard and setup form Co-Authored-By: Claude Opus 5 (1M context) --- runners/cli/src/ui/bridge.ts | 12 + runners/cli/src/ui/server.ts | 77 +- runners/cli/tests/knowledge.test.ts | 5 +- runners/cli/tests/uiEnvCheck.test.ts | 47 + runners/cli/tests/uiServerShutdown.test.ts | 44 + runners/cli/ui/index.html | 5 +- runners/cli/ui/package-lock.json | 20 + runners/cli/ui/package.json | 2 + runners/cli/ui/public/favicon.svg | 66 + runners/cli/ui/src/assets/opfor-wordmark.svg | 68 + .../ui/src/components/ConversationView.tsx | 3 +- .../cli/ui/src/components/FindingsPanel.tsx | 2 +- runners/cli/ui/src/components/SetupPage.tsx | 392 +++++- runners/cli/ui/src/components/StatusBar.tsx | 3 +- runners/cli/ui/src/styles/index.css | 1226 +++++++++++++---- 15 files changed, 1617 insertions(+), 355 deletions(-) create mode 100644 runners/cli/tests/uiEnvCheck.test.ts create mode 100644 runners/cli/tests/uiServerShutdown.test.ts create mode 100644 runners/cli/ui/public/favicon.svg create mode 100644 runners/cli/ui/src/assets/opfor-wordmark.svg diff --git a/runners/cli/src/ui/bridge.ts b/runners/cli/src/ui/bridge.ts index c66c1a01..a9524b97 100644 --- a/runners/cli/src/ui/bridge.ts +++ b/runners/cli/src/ui/bridge.ts @@ -64,6 +64,18 @@ export class UiBridge implements ProgressReporter { this.clients.delete(client); } + /** End every open SSE response so the HTTP server can finish closing. */ + closeAllClients(): void { + for (const client of this.clients) { + try { + client.close(); + } catch { + // Already torn down by the peer — nothing to do. + } + } + this.clients.clear(); + } + snapshot(): UiRunState { if (this.overrideState) return this.overrideState; if (!this.runLog) { diff --git a/runners/cli/src/ui/server.ts b/runners/cli/src/ui/server.ts index a81f2e18..3fe8bfda 100644 --- a/runners/cli/src/ui/server.ts +++ b/runners/cli/src/ui/server.ts @@ -17,6 +17,16 @@ import type { RunEvent } from "@keyvaluesystems/agent-opfor-core/autonomous/stat import { UiBridge, type SseClient } from "./bridge.js"; import type { SnapshotMeta } from "./snapshot.js"; +/** + * The setup form's POST body. Every field arrives as a string except `headers` + * (a name→value map) and the two booleans, which the form sends natively. + */ +interface SetupPayload extends Record { + headers?: Record; + sequential?: boolean; + verify?: boolean; +} + // Build the session config from the setup form's flat fields (see SetupPage.tsx). // A set-cookie receive must echo via the Cookie header regardless of the form's Send // fields; a body/header receive needs a non-blank name to be capturable at all. @@ -115,6 +125,8 @@ export async function startUiServer(options: UiServerOptions): Promise(); bridge.setMeta(options.meta); const staticDir = resolveStaticDir(); @@ -124,6 +136,18 @@ export async function startUiServer(options: UiServerOptions): Promise { + const name = typeof req.query.name === "string" ? req.query.name.trim() : ""; + if (!name) { + res.status(400).json({ error: "name is required" }); + return; + } + const value = process.env[name]; + res.json({ set: typeof value === "string" && value.length > 0 }); + }); + app.get("/api/state", (_req, res) => { res.json(bridge.snapshot()); }); @@ -168,7 +192,11 @@ export async function startUiServer(options: UiServerOptions): Promise { res.write(": keepalive\n\n"); }, 15000); - req.on("close", () => clearInterval(heartbeat)); + heartbeats.add(heartbeat); + req.on("close", () => { + clearInterval(heartbeat); + heartbeats.delete(heartbeat); + }); }); // Setup mode: start a run from the UI @@ -181,7 +209,9 @@ export async function startUiServer(options: UiServerOptions): Promise; + const body = req.body as SetupPayload; + // Everything except `headers` and the two booleans arrives as a string. + const config = body as unknown as Record; if (!config.endpoint) { res.status(400).json({ error: "Endpoint URL is required" }); @@ -194,21 +224,29 @@ export async function startUiServer(options: UiServerOptions): Promise = {}; + for (const [name, value] of Object.entries(body.headers ?? {})) { + if (name.trim() && typeof value === "string") headers[name.trim()] = value; + } + const target: TargetConfig = { name: targetName, endpoint: config.endpoint, apiKey, - headers: {}, + headers, mode, session, model: config.model || undefined, + promptPath: config.promptPath?.trim() || undefined, + responsePath: config.responsePath?.trim() || undefined, }; const intOr = (val: string | undefined, fallback: number): number => { @@ -217,6 +255,13 @@ export async function startUiServer(options: UiServerOptions): Promise { + if (!val?.trim()) return undefined; + const n = parseInt(val, 10); + return Number.isNaN(n) ? undefined : n; + }; + // Resolve model aliases to full model IDs from env vars if available const resolveModel = (alias: string | undefined, fallback: string): string => { const a = alias || fallback; @@ -241,14 +286,16 @@ export async function startUiServer(options: UiServerOptions): Promise { server.close((err) => (err ? reject(err) : resolve())); + server.closeAllConnections(); }); }, }; diff --git a/runners/cli/tests/knowledge.test.ts b/runners/cli/tests/knowledge.test.ts index bc3031c6..923a1111 100644 --- a/runners/cli/tests/knowledge.test.ts +++ b/runners/cli/tests/knowledge.test.ts @@ -8,8 +8,9 @@ test("loadKnowledge loads the bundled seed libraries", async () => { assert.ok(kb.personas.length >= 3, "expected several persona seeds"); assert.ok(kb.strategies.length >= 3, "expected several strategy seeds"); - const injection = kb.vulnClasses.find((v) => v.id === "prompt-injection"); - assert.ok(injection, "prompt-injection vuln-class present"); + // Ids come from the category directory names in HUNT_VULN_CLASS_CATEGORIES. + const injection = kb.vulnClasses.find((v) => v.id === "injection"); + assert.ok(injection, "injection vuln-class present"); assert.ok(injection!.failRubric.length > 0, "fail rubric parsed"); assert.ok(injection!.passRubric.length > 0, "pass rubric parsed"); assert.equal(injection!.severity, "critical"); diff --git a/runners/cli/tests/uiEnvCheck.test.ts b/runners/cli/tests/uiEnvCheck.test.ts new file mode 100644 index 00000000..e276d54c --- /dev/null +++ b/runners/cli/tests/uiEnvCheck.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startUiServer } from "../src/ui/server.js"; + +/** + * The setup form uses /api/env-check to tell the user a bearer-token env var is + * missing before the run starts, rather than surfacing it as a 401 mid-hunt. + * It reports existence only — the value must never leave the process. + */ +test("/api/env-check reports existence without leaking the value", async () => { + process.env.OPFOR_TEST_TOKEN_PRESENT = "super-secret-value"; + delete process.env.OPFOR_TEST_TOKEN_ABSENT; + + const handle = await startUiServer({ + port: 0, + meta: { objective: "test", targetName: "test" }, + openBrowser: false, + }); + const base = `http://127.0.0.1:${handle.port}/api/env-check`; + + try { + const present = await fetch(`${base}?name=OPFOR_TEST_TOKEN_PRESENT`); + const presentBody = await present.text(); + assert.equal(present.status, 200); + assert.deepEqual(JSON.parse(presentBody), { set: true }); + assert.ok( + !presentBody.includes("super-secret-value"), + "response must not echo the env var value" + ); + + const absent = await fetch(`${base}?name=OPFOR_TEST_TOKEN_ABSENT`); + assert.equal(absent.status, 200); + assert.deepEqual(await absent.json(), { set: false }); + + // An env var set to the empty string is not usable as a credential. + process.env.OPFOR_TEST_TOKEN_BLANK = ""; + const blank = await fetch(`${base}?name=OPFOR_TEST_TOKEN_BLANK`); + assert.deepEqual(await blank.json(), { set: false }); + + const missingName = await fetch(base); + assert.equal(missingName.status, 400); + } finally { + delete process.env.OPFOR_TEST_TOKEN_PRESENT; + delete process.env.OPFOR_TEST_TOKEN_BLANK; + await handle.close(); + } +}); diff --git a/runners/cli/tests/uiServerShutdown.test.ts b/runners/cli/tests/uiServerShutdown.test.ts new file mode 100644 index 00000000..4326a638 --- /dev/null +++ b/runners/cli/tests/uiServerShutdown.test.ts @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startUiServer } from "../src/ui/server.js"; + +/** + * Regression: `opfor hunt --ui` used to ignore Ctrl+C until the dashboard tab was + * reloaded or closed. `server.close()` waits for every open connection to end, and + * the /api/events SSE response never ends on its own — so the close callback could + * not fire while a browser was watching. Shutdown now tears the streams down first. + */ +test("close() resolves while a dashboard SSE client is still connected", async () => { + const handle = await startUiServer({ + port: 0, // let the OS pick, so parallel runs never collide + meta: { objective: "test", targetName: "test" }, + openBrowser: false, + }); + + const ac = new AbortController(); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/events`, { + signal: ac.signal, + headers: { accept: "text/event-stream" }, + }); + assert.equal(res.status, 200); + + // Read the first payload so the client is registered server-side before we close. + const reader = res.body!.getReader(); + await reader.read(); + + let timer: NodeJS.Timeout | undefined; + const hangGuard = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("close() did not resolve with a live SSE client — shutdown hangs")), + 5000 + ); + }); + + try { + await Promise.race([handle.close(), hangGuard]); + } finally { + clearTimeout(timer); + await reader.cancel().catch(() => {}); + ac.abort(); + } +}); diff --git a/runners/cli/ui/index.html b/runners/cli/ui/index.html index ba2cc9aa..85dfc9f2 100644 --- a/runners/cli/ui/index.html +++ b/runners/cli/ui/index.html @@ -3,7 +3,10 @@ - Opfor Hunt — Live Run + + + + Agent OPFOR — Live Hunt
diff --git a/runners/cli/ui/package-lock.json b/runners/cli/ui/package-lock.json index fe2469d1..fbbb0d5c 100644 --- a/runners/cli/ui/package-lock.json +++ b/runners/cli/ui/package-lock.json @@ -8,6 +8,8 @@ "name": "@keyvaluesystems/agent-opfor-autonomous-ui", "version": "0.10.1", "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -743,6 +745,24 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/runners/cli/ui/package.json b/runners/cli/ui/package.json index 749ecc48..1487f951 100644 --- a/runners/cli/ui/package.json +++ b/runners/cli/ui/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/runners/cli/ui/public/favicon.svg b/runners/cli/ui/public/favicon.svg new file mode 100644 index 00000000..bdddb743 --- /dev/null +++ b/runners/cli/ui/public/favicon.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runners/cli/ui/src/assets/opfor-wordmark.svg b/runners/cli/ui/src/assets/opfor-wordmark.svg new file mode 100644 index 00000000..24b5ed13 --- /dev/null +++ b/runners/cli/ui/src/assets/opfor-wordmark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runners/cli/ui/src/components/ConversationView.tsx b/runners/cli/ui/src/components/ConversationView.tsx index e47f8542..dbf2b155 100644 --- a/runners/cli/ui/src/components/ConversationView.tsx +++ b/runners/cli/ui/src/components/ConversationView.tsx @@ -21,7 +21,6 @@ function TurnCard({
{turnNumber} - {isFail && }
@@ -63,7 +62,7 @@ export function ConversationView({ thread, findings }: Props) { if (!thread) { return (
-
💬
+
</>

Select a thread to view the conversation

); diff --git a/runners/cli/ui/src/components/FindingsPanel.tsx b/runners/cli/ui/src/components/FindingsPanel.tsx index ac61b309..6c2fbede 100644 --- a/runners/cli/ui/src/components/FindingsPanel.tsx +++ b/runners/cli/ui/src/components/FindingsPanel.tsx @@ -22,7 +22,7 @@ export function FindingsPanel({ findings, selectedThreadId, onSelectThread }: Pr
{sorted.length === 0 ? (
-
🛡️
+
∅

No vulnerabilities found yet

) : ( diff --git a/runners/cli/ui/src/components/SetupPage.tsx b/runners/cli/ui/src/components/SetupPage.tsx index b71e5f3a..ab4224ba 100644 --- a/runners/cli/ui/src/components/SetupPage.tsx +++ b/runners/cli/ui/src/components/SetupPage.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; +import wordmark from "../assets/opfor-wordmark.svg"; interface Props { onStart: () => void; @@ -9,7 +10,11 @@ interface Config { model: string; targetName: string; objective: string; + // The TARGET's bearer token, read from this env var. Opfor's own commander/operator + // models authenticate separately via ANTHROPIC_API_KEY et al. apiKeyEnv: string; + promptPath: string; + responsePath: string; // Session handling: "stateless" (replay history), "client" (we send the id), // "server" (target returns its own id). send/receive location is body|header. sessionMode: string; @@ -24,6 +29,21 @@ interface Config { maxTurns: string; maxThreadTurns: string; budgetUsd: string; + maxTotalThreads: string; + maxForksPerThread: string; + maxDepth: string; + maxLeadsPerWave: string; + maxReconProbes: string; + maxTotalSends: string; + verifierModel: string; + sequential: boolean; + verify: boolean; +} + +interface HeaderRow { + id: number; + name: string; + value: string; } const defaultConfig: Config = { @@ -31,7 +51,9 @@ const defaultConfig: Config = { model: "", targetName: "", objective: "Probe for jailbreaks, system-prompt leakage, and safety bypasses.", - apiKeyEnv: "TARGET_API_KEY", + apiKeyEnv: "", + promptPath: "", + responsePath: "", sessionMode: "stateless", sessionSendIn: "body", sessionSendName: "session_id", @@ -44,10 +66,26 @@ const defaultConfig: Config = { maxTurns: "50", maxThreadTurns: "8", budgetUsd: "2", + maxTotalThreads: "40", + maxForksPerThread: "4", + maxDepth: "3", + maxLeadsPerWave: "4", + maxReconProbes: "8", + maxTotalSends: "", + verifierModel: "", + sequential: false, + verify: false, }; +type EnvStatus = "idle" | "checking" | "set" | "missing"; + export function SetupPage({ onStart }: Props) { const [config, setConfig] = useState(defaultConfig); + const [headers, setHeaders] = useState([]); + // A ref, not state: row ids only need to be unique React keys, and reading a + // counter out of state here would hand every add in the same tick the same id. + const headerIdRef = useRef(1); + const [envStatus, setEnvStatus] = useState("idle"); const [loading, setLoading] = useState(true); const [running, setRunning] = useState(false); const [error, setError] = useState(null); @@ -68,10 +106,41 @@ export function SetupPage({ onStart }: Props) { .finally(() => setLoading(false)); }, []); - const updateConfig = (key: keyof Config, value: string) => { + // Tell the user whether the named env var actually resolves, rather than letting + // them discover a typo as a 401 twenty seconds into a run. + useEffect(() => { + const name = config.apiKeyEnv.trim(); + if (!name) { + setEnvStatus("idle"); + return; + } + setEnvStatus("checking"); + const timer = setTimeout(() => { + fetch(`/api/env-check?name=${encodeURIComponent(name)}`) + .then((res) => res.json()) + .then((data: { set?: boolean }) => setEnvStatus(data.set ? "set" : "missing")) + .catch(() => setEnvStatus("idle")); + }, 350); + return () => clearTimeout(timer); + }, [config.apiKeyEnv]); + + const updateConfig = (key: K, value: Config[K]) => { setConfig((prev) => ({ ...prev, [key]: value })); }; + const addHeader = () => { + const id = headerIdRef.current++; + setHeaders((prev) => [...prev, { id, name: "", value: "" }]); + }; + + const updateHeader = (id: number, field: "name" | "value", value: string) => { + setHeaders((prev) => prev.map((h) => (h.id === id ? { ...h, [field]: value } : h))); + }; + + const removeHeader = (id: number) => { + setHeaders((prev) => prev.filter((h) => h.id !== id)); + }; + const handleStart = async () => { setError(null); @@ -84,13 +153,19 @@ export function SetupPage({ onStart }: Props) { return; } + const headerMap: Record = {}; + for (const h of headers) { + const name = h.name.trim(); + if (name) headerMap[name] = h.value; + } + setRunning(true); try { const res = await fetch("/api/start", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), + body: JSON.stringify({ ...config, headers: headerMap }), }); if (!res.ok) { @@ -108,8 +183,9 @@ export function SetupPage({ onStart }: Props) { if (loading) { return (
+ ); @@ -117,42 +193,50 @@ export function SetupPage({ onStart }: Props) { return (
+