feat: Werkzeug-Bestätigung, DNS-Pinning, Statistik-Leiste, Produktionsbetrieb (#1)

Ergebnis eines Security-Reviews des Werkzeug-Pfads plus die daraus
hervorgegangenen Verbesserungen.

Sicherheit:
- runTool wertet requiresConfirmation aus; Rückfrage über den WebSocket mit
  vollständigen Argumenten, Antwort allow/always/deny. Ohne Rückkanal gilt
  abgelehnt. read_webpage und remember sind bestätigungspflichtig
- remember pinnt nicht mehr automatisch, Modell-Anker verfallen normal
- SSRF-Guard mit gepinnter DNS-Auflösung (lookup-Hook statt fetch): geprüft
  wird genau die Adresse, die auch verbunden wird — schließt DNS-Rebinding
- ctx.signal kombiniert Abbruch und Zeitlimit bis in den Netzwerkabruf
- activeAborts als Set, Chat-Rate-Limit je Verbindung, Bucket-Cleanup

Statistik-Leiste über dem Composer: Tokens, tok/s, TTFT, Gesamtzeit und
Kontext-Füllstand, dazu die Session-Summe. Zahlen sind gemessen — bei Ollama
aus dem Abschluss-Chunk, bei OpenRouter aus dem usage-Block. Der Füllstand
rechnet gegen das tatsächlich genutzte Fenster aus /api/ps, nicht gegen die
deklarierte Länge des Modells.

Produktionsbetrieb: der Server liefert web/dist jetzt mit aus, npm start
genügt. /api und /ws behalten Vorrang.

README überarbeitet, Fork-Bezug entfernt, zerbrochene Konfigurationstabelle
repariert, API-Referenz ergänzt.

Nicht umgesetzt: Auth-/Origin-Härtung — der Server läuft bewusst lokal.
This commit is contained in:
Jeuner 2026-08-28 15:04:55 +02:00 committed by GitHub
parent 878acd9933
commit 8a489dfc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1495 additions and 347 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agenttwo-tools">
<rect width="32" height="32" rx="7" fill="#0a0f0c"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#1e2c23" stroke-width="1.5"/>
<path d="M8 10.5 L13.5 16 L8 21.5" fill="none" stroke="#3ddc84" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="16.5" y="19" width="8.5" height="3" rx="1.5" fill="#ffb454"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

15
web/dist/index.html vendored
View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-CQxQcFyH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -5,6 +5,8 @@ import { Sidebar } from "./components/Sidebar";
import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel";
import { ToolConfirm } from "./components/ToolConfirm";
import { StatsBar } from "./components/StatsBar";
import type { OpenRouterModel, OllamaModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode";
@ -48,6 +50,22 @@ export default function App() {
}
}, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]);
// Bei OpenRouter kennt nur der Client Preise und Kontextlänge — sie stehen
// in der Modellliste, nicht in der Antwort des Servers.
const activeOrModel =
chat.options.provider === "openrouter"
? orModels.find((m) => m.id === chat.options.openrouterModel)
: undefined;
const setModelPricing = chat.setModelPricing;
useEffect(() => {
setModelPricing(
activeOrModel
? { prompt: activeOrModel.promptPrice, completion: activeOrModel.completionPrice }
: null,
);
}, [activeOrModel, setModelPricing]);
const voiceModeRef = useRef(voiceMode);
const streamingRef = useRef(false);
const awaitingDrainRef = useRef(false);
@ -434,6 +452,10 @@ export default function App() {
))}
</div>
{chat.toolConfirm && (
<ToolConfirm request={chat.toolConfirm} onDecide={chat.decideToolConfirm} />
)}
{voice.error && (
<div className="voice-error">
{voice.error}
@ -441,6 +463,13 @@ export default function App() {
</div>
)}
<StatsBar
stats={chat.stats}
live={chat.live}
totals={chat.totals}
contextLength={activeOrModel?.contextLength}
/>
<Composer
streaming={chat.streaming}
disabled={!chat.activeId}

View file

@ -0,0 +1,87 @@
import type { ChatStats, SessionTotals } from "../types";
const nf = new Intl.NumberFormat("de-DE");
function seconds(ms: number): string {
return `${(ms / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 })} s`;
}
function rate(tokens: number, ms: number): string | null {
if (!tokens || ms <= 0) return null;
const perSecond = (tokens / ms) * 1000;
return `${perSecond.toLocaleString("de-DE", { maximumFractionDigits: 1 })} tok/s`;
}
/**
* Messwerte der laufenden bzw. letzten Antwort plus Session-Summe.
*
* Während des Streamens gibt es nur die Näherung aus gezählten Chunks die
* ist mit markiert. Die exakten Zahlen kommen vom Modell selbst, sobald die
* Antwort steht.
*/
export function StatsBar({
stats,
live,
totals,
contextLength,
}: {
stats: ChatStats | null;
live: { tokens: number; startedAt: number } | null;
totals: SessionTotals;
contextLength?: number;
}) {
const parts: string[] = [];
if (live) {
const elapsed = Date.now() - live.startedAt;
parts.push(`${nf.format(live.tokens)}`);
const r = rate(live.tokens, elapsed);
if (r) parts.push(`${r}`);
parts.push(seconds(elapsed));
} else if (stats) {
parts.push(`${nf.format(stats.promptTokens)}`);
parts.push(`${nf.format(stats.responseTokens)}`);
const r = rate(stats.responseTokens, stats.evalMs);
if (r) parts.push(r);
if (stats.ttftMs !== null) parts.push(`TTFT ${seconds(stats.ttftMs)}`);
parts.push(seconds(stats.totalMs));
if (stats.rounds > 1) parts.push(`${stats.rounds} Runden`);
}
const window = stats?.contextLength ?? contextLength;
const used = stats?.promptTokens ?? 0;
const fill = window && used ? Math.min(1, used / window) : null;
if (parts.length === 0 && totals.responses === 0) return null;
return (
<div className="stats-bar">
{parts.length > 0 && (
<span className={`stats-run ${live ? "streaming" : ""}`}>{parts.join(" · ")}</span>
)}
{fill !== null && window && (
<span
className={`stats-context ${fill > 0.9 ? "tight" : ""}`}
title={`Prompt der letzten Antwort gegen das tatsächlich genutzte Kontextfenster (${nf.format(window)} Tokens). Darüber hinaus wird vorne abgeschnitten.`}
>
<span className="stats-meter">
<span className="stats-meter-fill" style={{ width: `${fill * 100}%` }} />
</span>
{nf.format(used)} / {nf.format(window)}
</span>
)}
{totals.responses > 0 && (
<span
className="stats-totals"
title={`${totals.responses} Antworten in diesem Chat, seit dem letzten Neuladen der Seite`}
>
Σ {nf.format(totals.promptTokens)} {nf.format(totals.responseTokens)}
{totals.costUsd > 0 &&
` · $${totals.costUsd.toLocaleString("de-DE", { maximumFractionDigits: 4 })}`}
</span>
)}
</div>
);
}

View file

@ -0,0 +1,71 @@
import type { ToolConfirmRequest, ToolDecision } from "../types";
/** Kurzer Satz, was dieser Aufruf tatsächlich tut — pro Werkzeug. */
const WHAT_HAPPENS: Record<string, string> = {
read_webpage: "Diese Adresse wird von deinem Rechner abgerufen — inklusive allem, was in der URL steht.",
remember: "Dieser Punkt landet dauerhaft im Gedächtnis und geht künftig in jeden Chat mit ein.",
};
/** Argumente als lesbare Zeilen; unlesbares JSON fällt auf den Rohtext zurück. */
function argLines(args: string): [string, string][] {
try {
const parsed: unknown = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
k,
typeof v === "string" ? v : JSON.stringify(v),
]);
}
} catch {
/* unten als Rohtext */
}
return [["", args]];
}
export function ToolConfirm({
request,
onDecide,
}: {
request: ToolConfirmRequest;
onDecide: (id: string, decision: ToolDecision) => void;
}) {
return (
<div className="tool-confirm" role="alertdialog" aria-label="Werkzeug freigeben">
<div className="tool-confirm-head">
<span className="tool-confirm-mark"></span>
<span>
Das Modell möchte <code>{request.name}</code> ausführen
</span>
</div>
<dl className="tool-confirm-args">
{argLines(request.args).map(([key, value], i) => (
<div className="tool-confirm-arg" key={`${key}-${i}`}>
{key && <dt>{key}</dt>}
<dd>{value}</dd>
</div>
))}
</dl>
{WHAT_HAPPENS[request.name] && (
<p className="tool-confirm-hint">{WHAT_HAPPENS[request.name]}</p>
)}
<div className="tool-confirm-actions">
<button className="btn-decide deny" onClick={() => onDecide(request.id, "deny")}>
Ablehnen
</button>
<button className="btn-decide allow" onClick={() => onDecide(request.id, "allow")}>
Einmal zulassen
</button>
<button
className="btn-decide always"
title="Gilt für dieses Werkzeug, bis die Seite neu geladen wird"
onClick={() => onDecide(request.id, "always")}
>
Immer zulassen
</button>
</div>
</div>
);
}

View file

@ -896,3 +896,161 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Werkzeug-Freigabe --- */
.tool-confirm {
margin: 0 16px 10px;
padding: 12px 14px;
border: 1px solid var(--accent-warm);
border-radius: 8px;
background: rgba(255, 180, 84, 0.07);
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-confirm-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.tool-confirm-mark {
color: var(--accent-warm);
}
.tool-confirm-head code {
color: var(--accent-warm);
font-weight: 600;
}
.tool-confirm-args {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 160px;
overflow-y: auto;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg);
font-size: 0.85em;
}
.tool-confirm-arg {
display: flex;
gap: 8px;
}
.tool-confirm-arg dt {
flex: 0 0 auto;
color: var(--text-dim);
}
.tool-confirm-arg dd {
flex: 1 1 auto;
/* Umbrechen statt abschneiden: eine gekürzte URL wäre wertlos zum Prüfen. */
overflow-wrap: anywhere;
}
.tool-confirm-hint {
font-size: 0.82em;
color: var(--text-dim);
}
.tool-confirm-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-decide {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
font-family: var(--mono);
font-size: 0.85em;
cursor: pointer;
}
.btn-decide:hover {
border-color: var(--text-dim);
}
.btn-decide.allow {
border-color: var(--accent-dim);
color: var(--accent);
}
.btn-decide.allow:hover {
background: rgba(61, 220, 132, 0.1);
}
.btn-decide.always {
color: var(--text-dim);
}
.btn-decide.deny:hover {
border-color: #f85149;
color: #f85149;
}
.btn-decide:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* --- Statistik-Leiste --- */
.stats-bar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 5px 18px;
border-top: 1px solid var(--border);
font-size: 0.76em;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.stats-run.streaming {
color: var(--accent);
}
.stats-context {
display: flex;
align-items: center;
gap: 6px;
cursor: help;
}
.stats-meter {
width: 54px;
height: 4px;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.stats-meter-fill {
display: block;
height: 100%;
background: var(--accent-dim);
transition: width 0.3s ease;
}
.stats-context.tight {
color: var(--accent-warm);
}
.stats-context.tight .stats-meter-fill {
background: var(--accent-warm);
}
.stats-totals {
margin-left: auto;
cursor: help;
opacity: 0.75;
}

View file

@ -31,6 +31,44 @@ export interface ToolEvent {
durationMs?: number;
}
/**
* Rückfrage des Servers, bevor ein Werkzeug mit Außenwirkung läuft.
* `args` ist das vollständige JSON bei read_webpage steckt darin die URL,
* die der Rechner sonst ungefragt abrufen würde.
*/
export interface ToolConfirmRequest {
id: string;
messageId: string;
name: string;
args: string;
}
export type ToolDecision = "allow" | "always" | "deny";
/** Messwerte einer Antwort, wie der Server sie nach `done` schickt. */
export interface ChatStats {
messageId: string;
model: string;
provider: "ollama" | "openrouter";
promptTokens: number;
responseTokens: number;
ttftMs: number | null;
evalMs: number;
totalMs: number;
rounds: number;
/** Effektives Kontextfenster; bei OpenRouter aus der Modellliste ergänzt. */
contextLength?: number;
}
/** Aufsummiert über den Chat. Lebt im Browser und ist nach Reload weg. */
export interface SessionTotals {
promptTokens: number;
responseTokens: number;
responses: number;
/** Geschätzte Kosten in USD; nur bei OpenRouter mit bekannten Preisen. */
costUsd: number;
}
export interface ChatOptions {
model: string;
think: boolean;
@ -90,7 +128,9 @@ export interface OpenRouterModel {
id: string;
name: string;
contextLength: number;
/** Preis je 1 Mio. Tokens in USD. */
promptPrice: number;
completionPrice: number;
}
export interface ModelInfo {

View file

@ -1,6 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatSocket } from "./socket";
import type { ChatOptions, Message, Session , ToolEvent } from "./types";
import type {
ChatOptions,
ChatStats,
Message,
Session,
SessionTotals,
ToolConfirmRequest,
ToolDecision,
ToolEvent,
} from "./types";
export type ConnStatus = "connecting" | "open" | "closed";
export interface ModelInfo {
@ -46,6 +55,21 @@ export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({});
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
const [stats, setStats] = useState<ChatStats | null>(null);
// Während des Streamens gibt es noch keine exakten Zahlen: der Server meldet
// sie erst am Ende. Bis dahin zählt die Leiste die eingehenden Chunks als
// Näherung und misst die Zeit ab dem ersten Token.
const [live, setLive] = useState<{ tokens: number; startedAt: number } | null>(null);
const [totals, setTotals] = useState<SessionTotals>({
promptTokens: 0,
responseTokens: 0,
responses: 0,
costUsd: 0,
});
const priceRef = useRef<{ prompt: number; completion: number } | null>(null);
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
const [systemPrompt, setSystemPromptState] = useState(
() => localStorage.getItem(SYSTEM_KEY) ?? "",
@ -83,6 +107,11 @@ export function useChat() {
);
} else if (t === "token") {
const text = data.text as string;
setLive((cur) =>
cur
? { ...cur, tokens: cur.tokens + 1 }
: { tokens: 1, startedAt: Date.now() },
);
setMessages((prev) =>
prev.map((m) =>
m.id === data.messageId ? { ...m, content: m.content + text } : m,
@ -112,9 +141,42 @@ export function useChat() {
);
return { ...prev, [data.messageId as string]: updated };
});
} else if (t === "stats") {
const s = data as unknown as ChatStats;
setStats(s);
setLive(null);
setTotals((prev) => {
const price = priceRef.current;
const cost =
s.provider === "openrouter" && price
? (s.promptTokens * price.prompt + s.responseTokens * price.completion) /
1_000_000
: 0;
return {
promptTokens: prev.promptTokens + s.promptTokens,
responseTokens: prev.responseTokens + s.responseTokens,
responses: prev.responses + 1,
costUsd: prev.costUsd + cost,
};
});
} else if (t === "tool-confirm") {
setToolConfirms((prev) => [
...prev,
{
id: data.id as string,
messageId: data.messageId as string,
name: data.name as string,
args: data.args as string,
},
]);
} else if (t === "done" || t === "error") {
streamingRef.current = false;
setStreaming(false);
setLive(null);
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
setToolConfirms((prev) =>
prev.filter((c) => c.messageId !== (data.messageId as string)),
);
} else if (t === "sessions-changed" || t === "session-deleted") {
void refreshSessions();
}
@ -184,6 +246,8 @@ export function useChat() {
}
streamingRef.current = true;
setStreaming(true);
setLive(null);
setStats(null);
socketRef.current?.send({
type: "chat",
sessionId: activeId,
@ -199,6 +263,12 @@ export function useChat() {
const abort = useCallback(() => {
socketRef.current?.send({ type: "abort" });
setToolConfirms([]);
}, []);
const decideToolConfirm = useCallback((id: string, decision: ToolDecision) => {
socketRef.current?.send({ type: "tool-confirm-reply", id, decision });
setToolConfirms((prev) => prev.filter((c) => c.id !== id));
}, []);
const newSession = useCallback(async () => {
@ -226,6 +296,14 @@ export function useChat() {
messages,
streaming,
toolEvents,
toolConfirm: toolConfirms[0] ?? null,
decideToolConfirm,
stats,
live,
totals,
setModelPricing: (p: { prompt: number; completion: number } | null) => {
priceRef.current = p;
},
sendMessage,
abort,
newSession,