diff --git a/.env.example b/.env.example index becf62f6a..213b1412d 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,29 @@ CLOUDFLARE_AI_GATEWAY_API_KEY=your-cloudflare-ai-gateway-key-here # CUSTOM_OPENAI_API_KEY=... # CUSTOM_OPENAI_MODEL=gpt-4o-mini +# --- Mittwald AI Hosting (OpenAI-compatible) --- +# MITTWALD_AI_BASE_URL=https://llm.aihosting.mittwald.de/v1 +# MITTWALD_AI_KEY=... +# MITTWALD_AI_MODEL=Mistral-Medium-3.5-128B + +# --- Optional Mullvad egress for the sandbox only --- +# Enable with docker-compose.mullvad.yml. This does NOT route the web UI, +# LiteLLM, Postgres, Neo4j, or Tailscale through Mullvad; only the sandbox +# shares the VPN gateway network namespace. +# +# Values come from a Mullvad WireGuard configuration. The private key is the +# `PrivateKey` from the downloaded WireGuard config JSON/conf, not the public +# "WireGuard key" displayed on the device page. +# MULLVAD_WIREGUARD_PRIVATE_KEY= +# MULLVAD_WIREGUARD_ADDRESSES=10.x.y.z/32 +# MULLVAD_SERVER_COUNTRIES=Germany +# MULLVAD_SERVER_CITIES= +# MULLVAD_SERVER_HOSTNAMES= +# Keep this broad default for Docker Desktop control-plane reachability. Set +# it to the exact sandbox-net subnet if your in-scope target is on a private +# RFC1918 network and you need that target traffic to go through the VPN. +# MULLVAD_LOCAL_SUBNETS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 + # --- Local LLM (Ollama) --- # Set both to run Decepticon against a local Ollama model. The # launcher's onboard wizard writes these when you pick "Local LLM". diff --git a/clients/cli/src/commands/model.ts b/clients/cli/src/commands/model.ts index 5f44e1562..8dd0c44fb 100644 --- a/clients/cli/src/commands/model.ts +++ b/clients/cli/src/commands/model.ts @@ -68,6 +68,7 @@ const SUPPORTED_MODELS: Record = { "openrouter/anthropic/claude-opus-4-7", "openrouter/anthropic/claude-sonnet-4-6", "openrouter/anthropic/claude-haiku-4-5", + "openrouter/moonshotai/kimi-k2", ], "NVIDIA NIM": [ "nvidia_nim/meta/llama-3.3-70b-instruct", @@ -115,6 +116,7 @@ const SUPPORTED_MODELS: Record = { "cohere_chat/command-r", ], "Moonshot Kimi K2": [ + "openrouter/moonshotai/kimi-k2", "moonshot/kimi-k2-instruct", "moonshot/moonshot-v1-128k", "moonshot/moonshot-v1-8k", @@ -173,9 +175,10 @@ const SUPPORTED_MODELS: Record = { "cfgateway/anthropic/claude-sonnet-4-6", "cfgateway/anthropic/claude-haiku-4-5", ], + "Mittwald": ["mittwald/Mistral-Medium-3.5-128B"], // Local - "Ollama (local)": ["ollama_chat/qwen3-coder:30b (or your OLLAMA_MODEL)"], - "Ollama Cloud": ["ollama_chat/"], + "Ollama (local)": ["ollama_chat/qwen2.5-coder:7b", "ollama_chat/qwen3-coder:30b"], + "Ollama Cloud": ["ollama_cloud/kimi-k2.6"], "LM Studio (local)": ["lm_studio/"], "Custom OpenAI Endpoint": ["custom/"], }; diff --git a/clients/cli/src/commands/modelOverride.ts b/clients/cli/src/commands/modelOverride.ts index 3319fa71d..94e2a7928 100644 --- a/clients/cli/src/commands/modelOverride.ts +++ b/clients/cli/src/commands/modelOverride.ts @@ -10,7 +10,22 @@ * Empty string == no override. */ -let _override = ""; +let _override = (process.env.DECEPTICON_MODEL_OVERRIDE ?? "").trim(); +let _roleOverrides: Record = {}; + +try { + const raw = (process.env.DECEPTICON_MODEL_OVERRIDES ?? "").trim(); + const parsed = raw ? JSON.parse(raw) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + _roleOverrides = Object.fromEntries( + Object.entries(parsed as Record) + .filter(([, model]) => typeof model === "string" && model.trim()) + .map(([role, model]) => [role, (model as string).trim()]), + ); + } +} catch { + _roleOverrides = {}; +} export function setModelOverride(id: string): void { _override = id.trim(); @@ -19,3 +34,7 @@ export function setModelOverride(id: string): void { export function getModelOverride(): string { return _override; } + +export function getModelOverrides(): Record { + return { ..._roleOverrides }; +} diff --git a/clients/cli/src/components/Banner.tsx b/clients/cli/src/components/Banner.tsx index cf27a5e53..ff11046c8 100644 --- a/clients/cli/src/components/Banner.tsx +++ b/clients/cli/src/components/Banner.tsx @@ -47,6 +47,8 @@ const BANNER_LOGO = extractLogo(BANNER_FULL); const BANNER_LOGO_WIDTH = Math.max( ...BANNER_LOGO.split("\n").map((l) => l.length), ); +const COMPACT_FULL_WIDTH = Math.max(BANNER_FULL_WIDTH, 220); +const COMPACT_LOGO_WIDTH = Math.max(BANNER_LOGO_WIDTH, 110); // ── Responsive banner component ────────────────────────────────────── @@ -55,12 +57,12 @@ export const Banner = React.memo(function Banner() { const cols = stdout?.columns ?? 80; // Wide terminal — full banner with logo + text art - if (cols >= BANNER_FULL_WIDTH) { + if (cols >= COMPACT_FULL_WIDTH) { return {BANNER_FULL}; } // Medium terminal — braille logo + text name below - if (cols >= BANNER_LOGO_WIDTH) { + if (cols >= COMPACT_LOGO_WIDTH) { return ( {BANNER_LOGO} diff --git a/clients/cli/src/hooks/useAgent.ts b/clients/cli/src/hooks/useAgent.ts index f2fd8d4fd..8d81cbb7c 100644 --- a/clients/cli/src/hooks/useAgent.ts +++ b/clients/cli/src/hooks/useAgent.ts @@ -24,7 +24,7 @@ import { extractText, stripResultTags, } from "@decepticon/streaming"; -import { getModelOverride } from "../commands/modelOverride.js"; +import { getModelOverride, getModelOverrides } from "../commands/modelOverride.js"; import { getAssistantOverride } from "../commands/assistantOverride.js"; interface LangChainMessage { @@ -58,6 +58,25 @@ interface PendingTool { args: Record; } +function buildRunConfig(): { config?: { configurable: Record } } { + const configurable: Record = {}; + const slug = process.env.DECEPTICON_ENGAGEMENT; + if (slug) { + configurable.engagement_name = slug; + configurable.workspace_path = + process.env.DECEPTICON_WORKSPACE_PATH ?? "/workspace"; + } + const modelOverride = getModelOverride(); + if (modelOverride) { + configurable.model_override = modelOverride; + } + const modelOverrides = getModelOverrides(); + if (Object.keys(modelOverrides).length > 0) { + configurable.model_overrides = modelOverrides; + } + return Object.keys(configurable).length > 0 ? { config: { configurable } } : {}; +} + export interface StreamStats { startTime: number; totalTokens: number; @@ -765,28 +784,13 @@ export function useAgent({ messages: [{ role: "user", content: message }], }; - const configurable: Record = {}; - const slug = process.env.DECEPTICON_ENGAGEMENT; - if (slug) { - configurable.engagement_name = slug; - configurable.workspace_path = - process.env.DECEPTICON_WORKSPACE_PATH ?? "/workspace"; - } - const modelOverride = getModelOverride(); - if (modelOverride) { - configurable.model_override = modelOverride; - } - - const hasConfigurable = Object.keys(configurable).length > 0; - const streamConfig = hasConfigurable ? { configurable } : undefined; - try { const stream = client.runs.stream( threadIdRef.current!, getAssistantOverride() || assistantIdRef.current, { input, - ...(streamConfig ? { config: streamConfig } : {}), + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, @@ -872,6 +876,7 @@ export function useAgent({ getAssistantOverride() || assistantIdRef.current, { command: { resume: value }, + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, @@ -934,6 +939,7 @@ export function useAgent({ getAssistantOverride() || assistantIdRef.current, { command: { resume: value ?? true }, + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, diff --git a/clients/launcher/internal/ui/theme.go b/clients/launcher/internal/ui/theme.go index 548e2e032..51033fa35 100644 --- a/clients/launcher/internal/ui/theme.go +++ b/clients/launcher/internal/ui/theme.go @@ -76,8 +76,8 @@ const bannerLogo = "" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⡿⡯⠃\n" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁" -const bannerFullWidth = 165 -const bannerLogoWidth = 60 +const bannerFullWidth = 220 +const bannerLogoWidth = 110 // RenderBanner returns a responsive banner based on terminal width. func RenderBanner() string { diff --git a/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql b/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql new file mode 100644 index 000000000..df1832f28 --- /dev/null +++ b/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Engagement" ADD COLUMN "modelOverride" TEXT; diff --git a/clients/web/prisma/migrations/20260708153000_add_engagement_model_overrides/migration.sql b/clients/web/prisma/migrations/20260708153000_add_engagement_model_overrides/migration.sql new file mode 100644 index 000000000..b8bd3e06f --- /dev/null +++ b/clients/web/prisma/migrations/20260708153000_add_engagement_model_overrides/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Engagement" ADD COLUMN "modelOverrides" JSONB; diff --git a/clients/web/prisma/schema.prisma b/clients/web/prisma/schema.prisma index ea989d3e7..7ba6aa98c 100644 --- a/clients/web/prisma/schema.prisma +++ b/clients/web/prisma/schema.prisma @@ -36,7 +36,9 @@ model Engagement { userId String @default("local") // LangGraph thread tracking - threadId String? + threadId String? + modelOverride String? + modelOverrides Json? workspacePath String? createdAt DateTime @default(now()) diff --git a/clients/web/server/terminal-server.ts b/clients/web/server/terminal-server.ts index fc27d7620..8446a96b3 100755 --- a/clients/web/server/terminal-server.ts +++ b/clients/web/server/terminal-server.ts @@ -29,7 +29,8 @@ import { WebSocketServer, WebSocket } from "ws"; import * as pty from "node-pty"; -import { resolve, dirname } from "path"; +import * as fs from "fs"; +import { resolve, dirname, join } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -41,6 +42,7 @@ const CLI_PATH = resolve(__dirname, "../../cli/src/index.tsx"); const LANGGRAPH_API_URL = process.env.LANGGRAPH_API_URL ?? "http://localhost:2024"; const ORPHAN_TTL = 60_000; // Kill orphaned PTYs after 60s with no WS const SCROLLBACK_LIMIT = 50_000; // chars of recent output to buffer for reattach +const DEFAULT_BLOCKED_MODEL_PATTERNS = ["wormgpt", "uncensored", "abliterate"]; const ALLOWED_ORIGINS = new Set( (process.env.TERMINAL_ALLOWED_ORIGINS ?? `http://localhost:${WEB_PORT},http://127.0.0.1:${WEB_PORT}`) @@ -63,6 +65,8 @@ interface Session { orphanTimer: ReturnType | null; dead: boolean; // PTY exited exitCode: number | null; + modelOverride: string; + modelOverridesJson: string; } const sessions = new Map(); @@ -130,6 +134,59 @@ function persistThreadId(engagementId: string, threadId: string): void { }).catch(() => {}); } +function isBlockedModelId(value: string): boolean { + const blockedPatterns = readBlockedModelPatterns(); + const lower = value.toLowerCase(); + return blockedPatterns.some((part) => lower.includes(part.toLowerCase())); +} + +function modelPolicyPath(): string { + return ( + process.env.MODEL_POLICY_PATH ?? + join(process.env.WORKSPACE_PATH ?? "/workspace", ".decepticon", "model-policy.json") + ); +} + +function readBlockedModelPatterns(): string[] { + try { + const parsed = JSON.parse(fs.readFileSync(modelPolicyPath(), "utf8")) as { + blockedPatterns?: unknown; + }; + const values = Array.isArray(parsed.blockedPatterns) ? parsed.blockedPatterns : []; + const seen = new Set(); + return values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + .filter((pattern) => { + const key = pattern.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } catch { + return [...DEFAULT_BLOCKED_MODEL_PATTERNS]; + } +} + +function normalizeModelOverridesJson(raw: string): string { + if (!raw.trim()) return ""; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ""; + const clean: Record = {}; + for (const [role, model] of Object.entries(parsed as Record)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(role)) continue; + if (typeof model !== "string") continue; + const trimmed = model.trim(); + if (trimmed && trimmed.length <= 200 && !isBlockedModelId(trimmed)) clean[role] = trimmed; + } + return Object.keys(clean).length > 0 ? JSON.stringify(clean) : ""; + } catch { + return ""; + } +} + // ── Connection Handler ─────────────────────────────────────────── wss.on("connection", async (ws: WebSocket, req) => { @@ -142,6 +199,9 @@ wss.on("connection", async (ws: WebSocket, req) => { const engagementId = url.searchParams.get("engagementId") ?? ""; const engagementSlug = url.searchParams.get("engagementSlug") ?? ""; const agentId = url.searchParams.get("agentId") ?? "soundwave"; + const requestedModelOverride = (url.searchParams.get("modelOverride") ?? "").trim(); + const modelOverride = isBlockedModelId(requestedModelOverride) ? "" : requestedModelOverride; + const modelOverridesJson = normalizeModelOverridesJson(url.searchParams.get("modelOverrides") ?? ""); if (!engagementSlug) { ws.close(1008, "Missing engagementSlug"); @@ -153,32 +213,41 @@ wss.on("connection", async (ws: WebSocket, req) => { // ── Reattach to existing session ── if (session && !session.dead) { - console.log(`[terminal-server] Reattaching WS to existing session: ${key} (pid=${session.term.pid})`); + if ( + session.modelOverride !== modelOverride || + session.modelOverridesJson !== modelOverridesJson + ) { + console.log(`[terminal-server] Model config changed for ${key}; respawning PTY`); + destroySession(key); + session = undefined; + } else { + console.log(`[terminal-server] Reattaching WS to existing session: ${key} (pid=${session.term.pid})`); + + // Cancel orphan timer + if (session.orphanTimer) { + clearTimeout(session.orphanTimer); + session.orphanTimer = null; + } - // Cancel orphan timer - if (session.orphanTimer) { - clearTimeout(session.orphanTimer); - session.orphanTimer = null; - } + // Detach old WS if any. 4001 (app range) distinguishes "another connection + // took over" from the genuine PTY-exit 1000 the client treats as session end. + if (session.ws && session.ws !== ws && session.ws.readyState === WebSocket.OPEN) { + session.ws.close(4001, "Replaced by new connection"); + } + session.ws = ws; - // Detach old WS if any. 4001 (app range) distinguishes "another connection - // took over" from the genuine PTY-exit 1000 the client treats as session end. - if (session.ws && session.ws !== ws && session.ws.readyState === WebSocket.OPEN) { - session.ws.close(4001, "Replaced by new connection"); - } - session.ws = ws; + // Send threadId + if (session.threadId) sendJson(ws, { type: "threadId", threadId: session.threadId }); - // Send threadId - if (session.threadId) sendJson(ws, { type: "threadId", threadId: session.threadId }); + // Send scrollback so the client sees recent output without a full re-render + if (session.scrollback) { + sendJson(ws, { type: "reattached" }); + ws.send(session.scrollback); + } - // Send scrollback so the client sees recent output without a full re-render - if (session.scrollback) { - sendJson(ws, { type: "reattached" }); - ws.send(session.scrollback); + wireWsToSession(ws, session); + return; } - - wireWsToSession(ws, session); - return; } // ── Clean up dead session ── @@ -214,12 +283,14 @@ wss.on("connection", async (ws: WebSocket, req) => { DECEPTICON_API_URL: LANGGRAPH_API_URL, }; if (threadId) env.DECEPTICON_THREAD_ID = threadId; + if (modelOverride) env.DECEPTICON_MODEL_OVERRIDE = modelOverride; + if (modelOverridesJson) env.DECEPTICON_MODEL_OVERRIDES = modelOverridesJson; let term: pty.IPty; try { term = pty.spawn("node", ["--import", "tsx/esm", CLI_PATH], { name: "xterm-256color", - cols: 120, + cols: 80, rows: 30, cwd: resolve(__dirname, "../.."), env, @@ -244,6 +315,8 @@ wss.on("connection", async (ws: WebSocket, req) => { orphanTimer: null, dead: false, exitCode: null, + modelOverride, + modelOverridesJson, }; sessions.set(key, newSession); diff --git a/clients/web/server/web-proxy.ts b/clients/web/server/web-proxy.ts new file mode 100644 index 000000000..f8699ca2b --- /dev/null +++ b/clients/web/server/web-proxy.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Runtime proxy for the containerized web app. + * + * It keeps the dashboard and the embedded terminal on one public origin: + * regular HTTP goes to Next.js, while /terminal WebSocket upgrades go to the + * terminal bridge process. + */ + +import http, { type IncomingMessage } from "http"; +import net from "net"; + +const PORT = parseInt(process.env.WEB_PROXY_PORT ?? "3000", 10); +const NEXT_PORT = parseInt(process.env.NEXT_INTERNAL_PORT ?? "3001", 10); +const TERMINAL_PORT = parseInt(process.env.TERMINAL_PORT ?? "3003", 10); +const HOST = process.env.HOSTNAME ?? "0.0.0.0"; + +function requestPath(req: IncomingMessage): string { + return req.url ?? "/"; +} + +function isTerminalRequest(req: IncomingMessage): boolean { + return requestPath(req).startsWith("/terminal"); +} + +function proxyHttp(req: IncomingMessage, res: http.ServerResponse): void { + if (isTerminalRequest(req)) { + res.writeHead(426, { "content-type": "text/plain; charset=utf-8" }); + res.end("WebSocket upgrade required"); + return; + } + + const headers = { ...req.headers }; + headers.host = req.headers.host; + headers["x-forwarded-host"] = req.headers.host ?? ""; + headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] ?? "http"; + + const upstream = http.request( + { + host: "127.0.0.1", + port: NEXT_PORT, + method: req.method, + path: requestPath(req), + headers, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + }, + ); + + upstream.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + } + res.end(`Next.js upstream unavailable: ${err.message}`); + }); + + req.pipe(upstream); +} + +function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buffer): void { + const targetPort = isTerminalRequest(req) ? TERMINAL_PORT : NEXT_PORT; + const upstream = net.connect(targetPort, "127.0.0.1"); + + upstream.on("connect", () => { + upstream.write(`${req.method} ${requestPath(req)} HTTP/${req.httpVersion}\r\n`); + for (const [name, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const item of value) upstream.write(`${name}: ${item}\r\n`); + } else if (value !== undefined) { + upstream.write(`${name}: ${value}\r\n`); + } + } + upstream.write("\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + + upstream.on("error", () => { + if (!socket.destroyed) { + socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + socket.destroy(); + } + }); + + socket.on("error", () => upstream.destroy()); +} + +const server = http.createServer(proxyHttp); + +server.on("upgrade", proxyUpgrade); +server.on("clientError", (_err, socket) => { + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); +}); + +server.listen(PORT, HOST, () => { + console.log( + `[web-proxy] Listening on http://${HOST}:${PORT} ` + + `(next=127.0.0.1:${NEXT_PORT}, terminal=127.0.0.1:${TERMINAL_PORT})`, + ); +}); diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx index 52ec345a2..377b23190 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx @@ -173,9 +173,9 @@ export default function DocumentsPage() { const totalFiles = folders.reduce((sum, g) => sum + g.files.length, 0); return ( -
+
{/* Left: Folder tree */} -
+

Documents

@@ -247,22 +247,22 @@ export default function DocumentsPage() {

{/* Right: File content viewer */} - + - + - {selectedFile?.path ?? "Select a file"} + {selectedFile?.path ?? "Select a file"} - -
+ +
{fileLoading ? (
) : selectedFile ? ( -
+                
                   {selectedFile.content}
                 
) : ( diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx index 2ed9f6ef1..a4ba53718 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx @@ -214,7 +214,7 @@ export default function FindingsPage() { return (
-
+

Findings

@@ -222,7 +222,7 @@ export default function FindingsPage() {

setModelChoice(v ?? "default")}> + + + + + + Default + {DEFAULT_MODEL_OPTION.label} + + {BUILTIN_MODEL_GROUPS.map((group) => ( + + {group.label} + {group.models.map((model) => { + const blocked = isBlockedModel(model.value, modelPolicy.blockedPatterns); + return ( + + {blocked ? `${model.label} (gesperrt)` : model.label} + + ); + })} + + ))} + {(Object.keys(LOCAL_PROVIDER_LABELS) as LocalModel["provider"][]).map((provider) => { + const models = localModelsByProvider[provider] ?? []; + if (models.length === 0) return null; + return ( + + {LOCAL_PROVIDER_LABELS[provider]} + {models.map((model) => { + const blocked = isBlockedModel(model.id, modelPolicy.blockedPatterns); + return ( + + {blocked ? `${model.label} (gesperrt)` : model.label} + + ); + })} + + ); + })} + + Manual + Custom model ID + + + +
+ + {modelChoice === "custom" && ( +
+ + setCustomModel(e.target.value)} + /> +
+ )} + + + Target Configuration @@ -152,11 +332,11 @@ export default function NewEngagementPage() { -
- - diff --git a/clients/web/src/app/(dashboard)/engagements/page.tsx b/clients/web/src/app/(dashboard)/engagements/page.tsx index f452f5941..e386b3175 100644 --- a/clients/web/src/app/(dashboard)/engagements/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/page.tsx @@ -73,15 +73,15 @@ export default function EngagementsPage() { return (
-
+

Engagements

Manage your red team testing operations

- - @@ -108,7 +108,8 @@ export default function EngagementsPage() { one.
) : ( - +
+
Name @@ -122,7 +123,7 @@ export default function EngagementsPage() { const Icon = targetIcons[eng.targetType] ?? Globe; return ( - +
- + {eng.targetValue}
@@ -154,6 +155,7 @@ export default function EngagementsPage() { })}
+
)} diff --git a/clients/web/src/app/(dashboard)/layout.tsx b/clients/web/src/app/(dashboard)/layout.tsx index 978270a52..7b4e39ab1 100644 --- a/clients/web/src/app/(dashboard)/layout.tsx +++ b/clients/web/src/app/(dashboard)/layout.tsx @@ -1,5 +1,4 @@ import { Sidebar } from "@/components/layout/sidebar"; -import { Header } from "@/components/layout/header"; import { CommandPalette } from "@/components/layout/command-palette"; export default function DashboardLayout({ @@ -8,11 +7,10 @@ export default function DashboardLayout({ children: React.ReactNode; }) { return ( -
+
-
-
-
{children}
+
+
{children}
diff --git a/clients/web/src/app/api/engagements/[id]/route.ts b/clients/web/src/app/api/engagements/[id]/route.ts index 66d1a9c36..349516fc6 100644 --- a/clients/web/src/app/api/engagements/[id]/route.ts +++ b/clients/web/src/app/api/engagements/[id]/route.ts @@ -1,4 +1,5 @@ import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { assertModelAllowed } from "@/lib/model-policy"; import { prisma } from "@/lib/prisma"; import { SLUG_RE, VALID_TARGET_TYPES, VALID_STATUSES } from "@/lib/workspace"; import { NextRequest, NextResponse } from "next/server"; @@ -48,7 +49,15 @@ export async function PATCH( return NextResponse.json({ error: "Not found" }, { status: 404 }); } - const ALLOWED_FIELDS = ["name", "status", "targetType", "targetValue", "threadId"] as const; + const ALLOWED_FIELDS = [ + "name", + "status", + "targetType", + "targetValue", + "threadId", + "modelOverride", + "modelOverrides", + ] as const; const data: Record = {}; for (const field of ALLOWED_FIELDS) { if (field in body) data[field] = body[field]; @@ -87,6 +96,68 @@ export async function PATCH( ); } + if ("modelOverride" in data) { + if (data.modelOverride == null || data.modelOverride === "") { + data.modelOverride = null; + } else if (typeof data.modelOverride !== "string" || data.modelOverride.length > 200) { + return NextResponse.json( + { error: "Invalid modelOverride. Must be a model id up to 200 chars" }, + { status: 400 } + ); + } else { + try { + await assertModelAllowed(data.modelOverride); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, + { status: 400 } + ); + } + } + } + + if ("modelOverrides" in data) { + if (data.modelOverrides == null || data.modelOverrides === "") { + data.modelOverrides = null; + } else if ( + typeof data.modelOverrides !== "object" || + Array.isArray(data.modelOverrides) + ) { + return NextResponse.json( + { error: "Invalid modelOverrides. Must be an object of role to model id" }, + { status: 400 }, + ); + } else { + const clean: Record = {}; + for (const [role, model] of Object.entries(data.modelOverrides as Record)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(role)) { + return NextResponse.json( + { error: "Invalid modelOverrides role key" }, + { status: 400 }, + ); + } + if (model == null || model === "") continue; + if (typeof model !== "string" || model.length > 200) { + return NextResponse.json( + { error: "Invalid modelOverrides model id. Must be up to 200 chars" }, + { status: 400 }, + ); + } + const trimmed = model.trim(); + try { + await assertModelAllowed(trimmed); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, + { status: 400 }, + ); + } + if (trimmed) clean[role] = trimmed; + } + data.modelOverrides = Object.keys(clean).length > 0 ? clean : null; + } + } + const engagement = await prisma.engagement.update({ where: { id }, data, diff --git a/clients/web/src/app/api/engagements/route.ts b/clients/web/src/app/api/engagements/route.ts index cfb34e5cb..d4ec322f7 100644 --- a/clients/web/src/app/api/engagements/route.ts +++ b/clients/web/src/app/api/engagements/route.ts @@ -1,4 +1,5 @@ import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { assertModelAllowed } from "@/lib/model-policy"; import { prisma } from "@/lib/prisma"; import { SLUG_RE, VALID_TARGET_TYPES } from "@/lib/workspace"; import { NextRequest, NextResponse } from "next/server"; @@ -9,6 +10,27 @@ const WORKSPACE = process.env.WORKSPACE_PATH ?? path.join(process.env.HOME ?? "" const WORKSPACE_SUBDIRS = ["plan"]; +async function normalizeModelOverrides(value: unknown): Promise | null> { + if (value == null || value === "") return null; + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid modelOverrides. Must be an object of role to model id"); + } + + const clean: Record = {}; + for (const [role, model] of Object.entries(value as Record)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(role)) { + throw new Error("Invalid modelOverrides role key"); + } + if (typeof model !== "string" || model.length > 200) { + throw new Error("Invalid modelOverrides model id. Must be up to 200 chars"); + } + const trimmed = model.trim(); + await assertModelAllowed(trimmed); + if (trimmed) clean[role] = trimmed; + } + return Object.keys(clean).length > 0 ? clean : null; +} + function isEngagementWorkspaceDir(name: string) { return SLUG_RE.test(name) && !name.startsWith("."); } @@ -69,6 +91,19 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { name, targetType, targetValue } = body; + const modelOverride = + typeof body.modelOverride === "string" && body.modelOverride.trim() + ? body.modelOverride.trim() + : null; + let modelOverrides: Record | null = null; + try { + modelOverrides = await normalizeModelOverrides(body.modelOverrides); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Invalid modelOverrides" }, + { status: 400 } + ); + } if (!name || !targetType || !targetValue) { return NextResponse.json( @@ -84,6 +119,21 @@ export async function POST(req: NextRequest) { ); } + if (modelOverride && modelOverride.length > 200) { + return NextResponse.json( + { error: "Invalid modelOverride. Must be a model id up to 200 chars" }, + { status: 400 } + ); + } + try { + await assertModelAllowed(modelOverride ?? ""); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, + { status: 400 } + ); + } + // Engagement name doubles as the workspace slug. Enforce the same regex // the launcher uses so a name created here works as a folder name and a // future "Resume " picker entry without further escaping. @@ -112,6 +162,8 @@ export async function POST(req: NextRequest) { name, targetType, targetValue, + modelOverride, + ...(modelOverrides ? { modelOverrides } : {}), userId, workspacePath: wsPath, }, diff --git a/clients/web/src/app/api/local-models/route.ts b/clients/web/src/app/api/local-models/route.ts new file mode 100644 index 000000000..268b47c61 --- /dev/null +++ b/clients/web/src/app/api/local-models/route.ts @@ -0,0 +1,205 @@ +import { NextResponse } from "next/server"; + +type LocalModel = { + id: string; + label: string; + provider: "ollama" | "llamacpp" | "lmstudio" | "custom"; +}; + +type ProbeResult = { + provider: LocalModel["provider"]; + status: "ok" | "error" | "skipped"; + detail?: string; +}; + +const ROUTE_PREFIX: Record = { + ollama: "ollama_chat", + llamacpp: "llamacpp", + lmstudio: "lm_studio", + custom: "custom", +}; + +const LITELLM_URL = trimBaseUrl(process.env.LITELLM_URL) || "http://litellm:4000"; +const LITELLM_KEY = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY ?? ""; + +function trimBaseUrl(value: string | undefined): string { + return (value ?? "").trim().replace(/\/+$/, ""); +} + +function ollamaNativeBase(value: string | undefined): string { + const base = trimBaseUrl(value); + return base.replace(/\/v1$/i, ""); +} + +function uniqueModels(models: LocalModel[]): LocalModel[] { + const seen = new Set(); + return models.filter((model) => { + if (seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); +} + +async function fetchJson(url: string, headers?: HeadersInit): Promise { + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function registeredLiteLLMModels(): Promise> { + if (!LITELLM_KEY) return new Set(); + try { + const data = (await fetchJson(`${LITELLM_URL}/v1/models`, { + Authorization: `Bearer ${LITELLM_KEY}`, + })) as { data?: Array<{ id?: string }> }; + return new Set(data.data?.map((model) => model.id).filter((id): id is string => Boolean(id)) ?? []); + } catch { + return new Set(); + } +} + +function litellmParamsFor(model: LocalModel): Record { + if (model.provider === "ollama") { + const base = ollamaNativeBase(process.env.OLLAMA_API_BASE) || "http://host.docker.internal:11434"; + return { + model: model.id, + api_base: base, + }; + } + if (model.provider === "llamacpp") { + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.LLAMACPP_API_BASE), + api_key: process.env.LLAMACPP_API_KEY ? "os.environ/LLAMACPP_API_KEY" : "llama-cpp", + }; + } + if (model.provider === "lmstudio") { + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.LMSTUDIO_API_BASE), + api_key: process.env.LMSTUDIO_API_KEY ? "os.environ/LMSTUDIO_API_KEY" : "lm-studio", + }; + } + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.CUSTOM_OPENAI_API_BASE), + api_key: "os.environ/CUSTOM_OPENAI_API_KEY", + }; +} + +async function ensureLiteLLMModels(models: LocalModel[]): Promise { + if (!LITELLM_KEY) return { provider: "ollama", status: "skipped", detail: "LiteLLM key not configured" }; + const registered = await registeredLiteLLMModels(); + const missing = models.filter((model) => !registered.has(model.id)); + if (missing.length === 0) return { provider: "ollama", status: "ok", detail: "all local models registered" }; + + let added = 0; + for (const model of missing) { + try { + const res = await fetch(`${LITELLM_URL}/model/new`, { + method: "POST", + headers: { + Authorization: `Bearer ${LITELLM_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_name: model.id, + litellm_params: litellmParamsFor(model), + model_info: { source: model.provider, display_name: model.label }, + }), + signal: AbortSignal.timeout(5000), + }); + if (res.ok) added += 1; + } catch { + // Keep discovery usable even if a route could not be registered. + } + } + return { + provider: "ollama", + status: added === missing.length ? "ok" : "error", + detail: `registered ${added}/${missing.length} missing local models`, + }; +} + +async function listOllama(): Promise<{ models: LocalModel[]; result: ProbeResult }> { + const base = ollamaNativeBase(process.env.OLLAMA_API_BASE) || "http://host.docker.internal:11434"; + try { + const data = (await fetchJson(`${base}/api/tags`)) as { + models?: Array<{ name?: string; model?: string }>; + }; + const models = + data.models + ?.map((item) => (item.name ?? item.model ?? "").trim()) + .filter(Boolean) + .map((name) => ({ + id: `ollama_chat/${name}`, + label: name, + provider: "ollama" as const, + })) ?? []; + return { models, result: { provider: "ollama", status: "ok", detail: `${models.length} models` } }; + } catch (err) { + return { + models: [], + result: { + provider: "ollama", + status: "error", + detail: err instanceof Error ? err.message : "unreachable", + }, + }; + } +} + +async function listOpenAICompatible( + provider: "llamacpp" | "lmstudio" | "custom", + baseUrl: string | undefined, + apiKey: string | undefined, +): Promise<{ models: LocalModel[]; result: ProbeResult }> { + const base = trimBaseUrl(baseUrl); + if (!base) return { models: [], result: { provider, status: "skipped", detail: "base URL not configured" } }; + + const headers: HeadersInit = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + try { + const data = (await fetchJson(`${base}/models`, headers)) as { + data?: Array<{ id?: string }>; + }; + const models = + data.data + ?.map((item) => (item.id ?? "").trim()) + .filter(Boolean) + .map((name) => ({ + id: `${ROUTE_PREFIX[provider]}/${name}`, + label: name, + provider, + })) ?? []; + return { models, result: { provider, status: "ok", detail: `${models.length} models` } }; + } catch (err) { + return { + models: [], + result: { + provider, + status: "error", + detail: err instanceof Error ? err.message : "unreachable", + }, + }; + } +} + +export async function GET() { + const [ollama, llamacpp, lmstudio, custom] = await Promise.all([ + listOllama(), + listOpenAICompatible("llamacpp", process.env.LLAMACPP_API_BASE, process.env.LLAMACPP_API_KEY), + listOpenAICompatible("lmstudio", process.env.LMSTUDIO_API_BASE, process.env.LMSTUDIO_API_KEY), + listOpenAICompatible("custom", process.env.CUSTOM_OPENAI_API_BASE, process.env.CUSTOM_OPENAI_API_KEY), + ]); + + const models = uniqueModels([...ollama.models, ...llamacpp.models, ...lmstudio.models, ...custom.models]); + const registration = await ensureLiteLLMModels(models); + + return NextResponse.json({ + models, + sources: [ollama.result, llamacpp.result, lmstudio.result, custom.result, registration], + }); +} diff --git a/clients/web/src/app/api/model-policy/route.ts b/clients/web/src/app/api/model-policy/route.ts new file mode 100644 index 000000000..9da4b44ba --- /dev/null +++ b/clients/web/src/app/api/model-policy/route.ts @@ -0,0 +1,42 @@ +import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { readModelPolicy, writeModelPolicy } from "@/lib/model-policy"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET() { + try { + await requireAuth(); + return NextResponse.json(await readModelPolicy()); + } catch (e) { + if (e instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("GET /api/model-policy error:", e); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Internal server error" }, + { status: 500 }, + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + await requireAuth(); + const body = await req.json(); + if (!Array.isArray(body.blockedPatterns)) { + return NextResponse.json( + { error: "blockedPatterns must be an array" }, + { status: 400 }, + ); + } + return NextResponse.json(await writeModelPolicy(body.blockedPatterns)); + } catch (e) { + if (e instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("PATCH /api/model-policy error:", e); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/clients/web/src/components/engagement/engagement-model-picker.tsx b/clients/web/src/components/engagement/engagement-model-picker.tsx new file mode 100644 index 000000000..778583c38 --- /dev/null +++ b/clients/web/src/components/engagement/engagement-model-picker.tsx @@ -0,0 +1,563 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { BUILTIN_MODEL_GROUPS, BUILTIN_MODELS, DEFAULT_MODEL_OPTION } from "@/lib/model-options"; +import { cn } from "@/lib/utils"; +import { Ban, Check, RefreshCw, Save, Shield, SlidersHorizontal, X } from "lucide-react"; + +type LocalModel = { + id: string; + label: string; + provider: "ollama" | "llamacpp" | "lmstudio" | "custom"; +}; + +export type ModelOverrides = Record; + +type ModelPolicy = { + blockedPatterns: string[]; +}; + +const AGENT_ROLES = [ + { value: "soundwave", label: "Soundwave", hint: "planning" }, + { value: "decepticon", label: "Decepticon", hint: "orchestration" }, + { value: "recon", label: "Recon", hint: "discovery" }, + { value: "exploit", label: "Exploit", hint: "validation" }, + { value: "postexploit", label: "Post-Exploit", hint: "follow-up" }, + { value: "analyst", label: "Analyst", hint: "reasoning" }, + { value: "blue_cell", label: "Blue Cell", hint: "defense" }, + { value: "cloud_hunter", label: "Cloud Hunter", hint: "cloud" }, + { value: "ad_operator", label: "AD Operator", hint: "identity" }, + { value: "reverser", label: "Reverser", hint: "binaries" }, + { value: "forensicator", label: "Forensicator", hint: "evidence" }, + { value: "osint_operator", label: "OSINT Operator", hint: "passive" }, + { value: "mobile_operator", label: "Mobile Operator", hint: "mobile" }, + { value: "wireless_operator", label: "Wireless Operator", hint: "wireless" }, + { value: "iot_operator", label: "IoT Operator", hint: "devices" }, + { value: "ics_operator", label: "ICS Operator", hint: "safety" }, + { value: "contract_auditor", label: "Contract Auditor", hint: "contracts" }, + { value: "phisher", label: "Phisher", hint: "only if RoE allows" }, + { value: "supply_chain_operator", label: "Supply Chain", hint: "deps" }, +] as const; + +const LOCAL_PROVIDER_LABELS: Record = { + ollama: "Ollama", + llamacpp: "llama.cpp", + lmstudio: "LM Studio", + custom: "Custom OpenAI", +}; + +function choiceFromOverride(value: string | null | undefined): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return "default"; + if (BUILTIN_MODELS.some((model) => model.value === trimmed)) return trimmed; + return "custom"; +} + +function normalizeOverrides(value: ModelOverrides | null | undefined): ModelOverrides { + if (!value) return {}; + return Object.fromEntries( + Object.entries(value) + .map(([role, model]) => [role, model.trim()]) + .filter(([, model]) => Boolean(model)), + ); +} + +function isBlockedModel(id: string, patterns: string[]): boolean { + const lower = id.toLowerCase(); + return patterns.some((pattern) => lower.includes(pattern.toLowerCase())); +} + +function uniquePatterns(patterns: string[]): string[] { + const seen = new Set(); + const clean: string[] = []; + for (const pattern of patterns) { + const trimmed = pattern.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + clean.push(trimmed); + } + return clean; +} + +function isKnownModelChoice( + value: string, + localModelsByProvider: Partial>, +): boolean { + if (BUILTIN_MODELS.some((model) => model.value === value)) return true; + return Object.values(localModelsByProvider).some((models) => + (models ?? []).some((model) => model.id === value), + ); +} + +function RoleModelSelect({ + value, + blockedPatterns, + localModelsByProvider, + compact = false, + onChange, +}: { + value: string; + blockedPatterns: string[]; + localModelsByProvider: Partial>; + compact?: boolean; + onChange: (value: string) => void; +}) { + const [forceCustom, setForceCustom] = useState(false); + const trimmedValue = value.trim(); + const knownChoice = isKnownModelChoice(trimmedValue, localModelsByProvider); + const choice = forceCustom || (trimmedValue && !knownChoice) ? "custom" : trimmedValue || "default"; + const customValue = choice === "custom" && trimmedValue !== "default" ? trimmedValue : ""; + + useEffect(() => { + if (trimmedValue && knownChoice) setForceCustom(false); + }, [knownChoice, trimmedValue]); + + return ( +
+ + {choice === "custom" && ( + onChange(event.target.value)} + /> + )} +
+ ); +} + +export function EngagementModelPicker({ + engagementId, + value, + overrides, + variant = "bar", + onChange, +}: { + engagementId: string; + value: string; + overrides?: ModelOverrides | null; + variant?: "bar" | "sidebar"; + onChange: (value: string, overrides: ModelOverrides) => void; +}) { + const [choice, setChoice] = useState(choiceFromOverride(value)); + const [customModel, setCustomModel] = useState(choiceFromOverride(value) === "custom" ? value.trim() : ""); + const [roleOverrides, setRoleOverrides] = useState(() => normalizeOverrides(overrides)); + const [showRoles, setShowRoles] = useState(false); + const [showPolicy, setShowPolicy] = useState(false); + const [localModels, setLocalModels] = useState([]); + const [modelPolicy, setModelPolicy] = useState({ + blockedPatterns: ["wormgpt", "uncensored", "abliterate"], + }); + const [policyDraft, setPolicyDraft] = useState(""); + const [savingPolicy, setSavingPolicy] = useState(false); + const [loadingModels, setLoadingModels] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"idle" | "saved" | "error">("idle"); + + useEffect(() => { + const trimmed = (value ?? "").trim(); + const localMatch = localModels.some((model) => model.id === trimmed); + const next = localMatch ? trimmed : choiceFromOverride(trimmed); + setChoice(next); + if (next === "custom") setCustomModel(trimmed); + }, [localModels, value]); + + useEffect(() => { + setRoleOverrides(normalizeOverrides(overrides)); + }, [overrides]); + + const loadLocalModels = useCallback(async () => { + setLoadingModels(true); + try { + const res = await fetch("/api/local-models", { cache: "no-store" }); + if (!res.ok) throw new Error("failed"); + const data = (await res.json()) as { models?: LocalModel[] }; + setLocalModels(data.models ?? []); + } catch { + setLocalModels([]); + } finally { + setLoadingModels(false); + } + }, []); + + useEffect(() => { + void loadLocalModels(); + }, [loadLocalModels]); + + const loadModelPolicy = useCallback(async () => { + try { + const res = await fetch("/api/model-policy", { cache: "no-store" }); + if (!res.ok) throw new Error("failed"); + setModelPolicy((await res.json()) as ModelPolicy); + } catch { + setModelPolicy({ + blockedPatterns: ["wormgpt", "uncensored", "abliterate"], + }); + } + }, []); + + useEffect(() => { + void loadModelPolicy(); + }, [loadModelPolicy]); + + const localModelsByProvider = useMemo( + () => + localModels.reduce( + (groups, model) => { + const group = groups[model.provider] ?? []; + group.push(model); + groups[model.provider] = group; + return groups; + }, + {} as Partial>, + ), + [localModels], + ); + + const selectedModel = choice === "custom" ? customModel.trim() : choice === "default" ? "" : choice; + const cleanOverrides = normalizeOverrides(roleOverrides); + const blockedPatterns = modelPolicy.blockedPatterns; + const dirty = + selectedModel !== (value ?? "").trim() || + JSON.stringify(cleanOverrides) !== JSON.stringify(normalizeOverrides(overrides)); + + const selectableModels = useMemo( + () => [ + ...BUILTIN_MODELS.filter((model) => model.value !== "default").map((model) => ({ + id: model.value, + label: model.label, + provider: "cloud", + })), + ...localModels.map((model) => ({ + id: model.id, + label: model.label, + provider: LOCAL_PROVIDER_LABELS[model.provider], + })), + ], + [localModels], + ); + + async function savePolicy(nextPatterns: string[]) { + setSavingPolicy(true); + setStatus("idle"); + try { + const res = await fetch("/api/model-policy", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockedPatterns: uniquePatterns(nextPatterns) }), + }); + if (!res.ok) throw new Error("save failed"); + setModelPolicy((await res.json()) as ModelPolicy); + } catch { + setStatus("error"); + } finally { + setSavingPolicy(false); + } + } + + function blockModel(id: string) { + void savePolicy([...blockedPatterns, id]); + } + + function freeModel(id: string) { + const lower = id.toLowerCase(); + void savePolicy(blockedPatterns.filter((pattern) => !lower.includes(pattern.toLowerCase()))); + } + + function addPattern() { + if (!policyDraft.trim()) return; + void savePolicy([...blockedPatterns, policyDraft]); + setPolicyDraft(""); + } + + function updateRole(role: string, model: string) { + setStatus("idle"); + setRoleOverrides((current) => { + const next = { ...current }; + const trimmed = model.trim(); + if (trimmed) next[role] = trimmed; + else delete next[role]; + return next; + }); + } + + async function save() { + if (choice === "custom" && !customModel.trim()) return; + if ([selectedModel, ...Object.values(cleanOverrides)].some((model) => isBlockedModel(model, blockedPatterns))) { + setStatus("error"); + return; + } + setSaving(true); + setStatus("idle"); + try { + const res = await fetch(`/api/engagements/${engagementId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelOverride: selectedModel || null, + modelOverrides: Object.keys(cleanOverrides).length ? cleanOverrides : null, + }), + }); + if (!res.ok) throw new Error("save failed"); + const engagement = (await res.json()) as { + modelOverride?: string | null; + modelOverrides?: ModelOverrides | null; + }; + const nextModel = engagement.modelOverride ?? ""; + const nextOverrides = normalizeOverrides(engagement.modelOverrides); + onChange(nextModel, nextOverrides); + window.dispatchEvent( + new CustomEvent("decepticon:engagement-models-updated", { + detail: { + engagementId, + modelOverride: nextModel, + modelOverrides: nextOverrides, + }, + }), + ); + setStatus("saved"); + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + } + + const compact = variant === "sidebar"; + + return ( +
+
+ Model + { + const nextChoice = + next && isKnownModelChoice(next, localModelsByProvider) + ? next + : choiceFromOverride(next); + setChoice(nextChoice); + setCustomModel(nextChoice === "custom" ? next : ""); + setStatus("idle"); + }} + /> +
+ + + + +
+ {status === "saved" && Saved} + {status === "error" && Error} +
+ + {showPolicy && ( +
+
+ setPolicyDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addPattern(); + }} + /> + +
+ +
+ {blockedPatterns.map((pattern) => ( + + ))} +
+ +
+ {selectableModels.map((model) => { + const blocked = isBlockedModel(model.id, blockedPatterns); + return ( +
+
+
{model.label}
+
{model.provider} · {model.id}
+
+ +
+ ); + })} +
+
+ )} + + {showRoles && ( +
+ {AGENT_ROLES.map((role) => ( +
+
+
{role.label}
+
{role.hint}
+
+ updateRole(role.value, next)} + /> +
+ ))} +
+ )} +
+ ); +} diff --git a/clients/web/src/components/layout/command-palette.tsx b/clients/web/src/components/layout/command-palette.tsx index 10391e118..fbd78acdc 100644 --- a/clients/web/src/components/layout/command-palette.tsx +++ b/clients/web/src/components/layout/command-palette.tsx @@ -116,7 +116,7 @@ export function CommandPalette() {
{ setOpen(false); setQuery(""); }} /> {/* Palette */} -
+
{/* Search input */}
@@ -132,7 +132,7 @@ export function CommandPalette() {
{/* Results */} -
+
{filtered.length === 0 ? (

No commands found

) : ( @@ -151,14 +151,14 @@ export function CommandPalette() { onClick={() => execute(cmd)} onMouseEnter={() => setSelectedIndex(globalIdx)} className={cn( - "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors", + "flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors sm:gap-3 sm:px-3", isSelected ? "bg-white/[0.08] text-white" : "text-zinc-400 hover:bg-white/[0.04]", )} > - {cmd.label} + {cmd.label} {cmd.shortcut && ( - + {cmd.shortcut} )} diff --git a/clients/web/src/components/layout/header.tsx b/clients/web/src/components/layout/header.tsx index fd0dd5dfb..50f7ff654 100644 --- a/clients/web/src/components/layout/header.tsx +++ b/clients/web/src/components/layout/header.tsx @@ -6,22 +6,27 @@ import { Separator } from "@/components/ui/separator"; export function Header() { return ( -
-
-

+
+
+

Autonomous Red Team Platform

- - + + Online
-
+
- Local Mode + + Self-hosted +
); diff --git a/clients/web/src/components/layout/sidebar.tsx b/clients/web/src/components/layout/sidebar.tsx index e2c6064aa..6ec9a79c5 100644 --- a/clients/web/src/components/layout/sidebar.tsx +++ b/clients/web/src/components/layout/sidebar.tsx @@ -17,9 +17,11 @@ import { ChevronLeft, ChevronRight, ChevronDown, + Bell, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { EngagementModelPicker, type ModelOverrides } from "@/components/engagement/engagement-model-picker"; import { Tooltip, TooltipContent, @@ -56,6 +58,8 @@ const bottomNav: NavItem[] = [ interface Engagement { id: string; name: string; + modelOverride?: string | null; + modelOverrides?: ModelOverrides | null; } export function Sidebar() { @@ -63,6 +67,7 @@ export function Sidebar() { const [collapsed, setCollapsed] = useState(false); const [engDropdownOpen, setEngDropdownOpen] = useState(false); const [engagements, setEngagements] = useState([]); + const [activeEngagement, setActiveEngagement] = useState(null); // Derive engagement ID from pathname — only refetch when engagement context changes const engMatch = pathname.match(/^\/engagements\/([^/]+)/); @@ -84,8 +89,29 @@ export function Sidebar() { return () => { cancelled = true; }; }, [activeEngId]); + useEffect(() => { + if (!activeEngId) { + setActiveEngagement(null); + return; + } + + let cancelled = false; + fetch(`/api/engagements/${activeEngId}`, { cache: "no-store" }) + .then((res) => { + if (!res.ok) throw new Error("fetch failed"); + return res.json(); + }) + .then((data: Engagement) => { + if (!cancelled) setActiveEngagement(data); + }) + .catch(() => { + if (!cancelled) setActiveEngagement(null); + }); + return () => { cancelled = true; }; + }, [activeEngId]); + const activeEng = activeEngId - ? engagements.find((e) => e.id === activeEngId) ?? null + ? activeEngagement ?? engagements.find((e) => e.id === activeEngId) ?? null : null; function resolveHref(item: NavItem) { @@ -142,25 +168,62 @@ export function Sidebar() { return link; } + function renderMobileNavItem(item: NavItem) { + const href = resolveHref(item); + const active = isActive(item); + const disabled = item.engagementScoped && !activeEngId; + + return ( + disabled && e.preventDefault()} + > + + {item.label} + + ); + } + return ( + <> + + ); } diff --git a/clients/web/src/components/streaming/agent-detail-panel.tsx b/clients/web/src/components/streaming/agent-detail-panel.tsx index 03994695b..9657f9b51 100644 --- a/clients/web/src/components/streaming/agent-detail-panel.tsx +++ b/clients/web/src/components/streaming/agent-detail-panel.tsx @@ -143,7 +143,7 @@ export function AgentDetailPanel({ return (
diff --git a/clients/web/src/components/streaming/approval-gate.tsx b/clients/web/src/components/streaming/approval-gate.tsx index a998855ec..69623c677 100644 --- a/clients/web/src/components/streaming/approval-gate.tsx +++ b/clients/web/src/components/streaming/approval-gate.tsx @@ -191,13 +191,13 @@ function ApprovalCard({ request, engagementId, onDecided }: ApprovalCardProps) { {error &&

{error}

} -
+