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

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3",
"fastify": "^5.2.1",
"linkedom": "^0.18.13",

View file

@ -1,12 +1,14 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "node:http";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import * as dbmod from "./db.js";
import * as mem from "./memory.js";
import { streamChat, type OllamaOptions } from "./ollama.js";
import { streamChat, type ChatStats, type OllamaOptions } from "./ollama.js";
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
import {
streamOpenRouter,
@ -35,6 +37,46 @@ const PORT = Number(process.env.PORT ?? 8788);
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
const MODEL = process.env.MODEL ?? "qwen3.5:latest";
/**
* Wie lange auf die Freigabe eines bestätigungspflichtigen Werkzeugs gewartet
* wird. Danach gilt "abgelehnt" eine Antwort soll nicht ewig hängen, nur
* weil niemand am Rechner sitzt.
*/
const CONFIRM_TIMEOUT_MS = 120_000;
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
const CHAT_LIMIT_PER_MIN = 30;
/**
* Effektive Kontextlänge eines geladenen Ollama-Modells.
*
* Nicht dasselbe wie die im Modell deklarierte Länge: qwen3.5 meldet 262144,
* geladen läuft es je nach Ollama-Default aber mit 4096. Für einen ehrlichen
* Füllstand zählt nur, womit das Modell tatsächlich läuft und das steht in
* /api/ps. Kurz gecacht, weil sich das nur beim Nachladen ändert.
*/
const CONTEXT_TTL_MS = 30_000;
const contextCache = new Map<string, { value: number; at: number }>();
async function effectiveContextLength(model: string): Promise<number | undefined> {
const hit = contextCache.get(model);
if (hit && Date.now() - hit.at < CONTEXT_TTL_MS) return hit.value;
try {
const res = await fetch(`${OLLAMA_URL}/api/ps`);
if (!res.ok) return hit?.value;
const data = (await res.json()) as {
models?: { name?: string; model?: string; context_length?: number }[];
};
const entry = (data.models ?? []).find(
(m) => m.name === model || m.model === model,
);
if (!entry?.context_length) return hit?.value;
contextCache.set(model, { value: entry.context_length, at: Date.now() });
return entry.context_length;
} catch {
return hit?.value;
}
}
const DREAM_IDLE_MS = 180_000;
const DREAM_BATCH = 10;
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -268,6 +310,25 @@ app.get("/api/ollama/models", async () => {
}
});
// --- Gebautes Frontend ---
//
// Nur wenn web/dist existiert: im Entwicklungsbetrieb liefert der
// Vite-Server das Frontend aus, dann soll hier nichts danebenstehen.
// Registrierung nach den API-Routen, damit /api und /ws Vorrang behalten.
const WEB_DIST = path.join(import.meta.dirname, "..", "..", "web", "dist");
const hasBuild = existsSync(path.join(WEB_DIST, "index.html"));
if (hasBuild) {
await app.register(fastifyStatic, { root: WEB_DIST });
app.setNotFoundHandler((req, reply) => {
// API-Fehler bleiben JSON; alles andere bekommt die App.
if (req.url.startsWith("/api")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
// --- WebSocket ---
interface ChatOptionsPayload {
think: boolean;
@ -325,7 +386,54 @@ function broadcast(data: unknown) {
}
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
let activeAbort: AbortController | null = null;
// Mehrere Antworten können parallel laufen (zweite Nachricht bei laufendem
// Stream). Ein einzelnes Feld würde beim Abbruch nur die letzte erwischen.
const activeAborts = new Set<AbortController>();
const pendingConfirms = new Map<string, { name: string; decide(ok: boolean): void }>();
/** Werkzeuge, die der Nutzer für diese Verbindung generell freigegeben hat. */
const alwaysAllowed = new Set<string>();
const chatLimiter = createRateLimiter(CHAT_LIMIT_PER_MIN, 60_000);
function denyAllConfirms() {
for (const entry of [...pendingConfirms.values()]) entry.decide(false);
}
/**
* Fragt den Nutzer, bevor ein Werkzeug mit Außenwirkung läuft. Bricht die
* Verbindung weg oder bleibt die Antwort aus, gilt das als Ablehnung.
*/
function confirmTool(
messageId: string,
name: string,
args: Record<string, unknown>,
): Promise<boolean> {
if (alwaysAllowed.has(name)) return Promise.resolve(true);
if (socket.readyState !== WebSocket.OPEN) return Promise.resolve(false);
const id = randomUUID();
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => decide(false), CONFIRM_TIMEOUT_MS);
function decide(approved: boolean) {
clearTimeout(timer);
pendingConfirms.delete(id);
resolve(approved);
}
pendingConfirms.set(id, { name, decide });
socket.send(
JSON.stringify({
type: "tool-confirm",
id,
messageId,
name,
// Ungekürzt: der Nutzer muss genau sehen, was rausgeht — bei
// read_webpage ist die vollständige URL der eigentliche Punkt.
args: JSON.stringify(args),
}),
);
});
}
socket.on("close", denyAllConfirms);
socket.on("message", async (raw: Buffer) => {
let msg: Record<string, unknown>;
@ -336,13 +444,31 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
return;
}
if (msg.type === "tool-confirm-reply") {
const entry = pendingConfirms.get(String(msg.id ?? ""));
if (!entry) return;
// Der Werkzeugname kommt aus dem Server-Zustand, nicht aus der Antwort:
// sonst könnte eine Freigabe für ein Werkzeug ein anderes freischalten.
if (msg.decision === "always") alwaysAllowed.add(entry.name);
entry.decide(msg.decision === "allow" || msg.decision === "always");
return;
}
if (msg.type === "abort") {
activeAbort?.abort();
for (const controller of activeAborts) controller.abort();
denyAllConfirms();
return;
}
if (msg.type !== "chat") return;
if (!chatLimiter("chat")) {
socket.send(
JSON.stringify({ type: "error", error: "Zu viele Anfragen — kurz warten." }),
);
return;
}
const sessionId = String(msg.sessionId ?? "");
const content = String(msg.content ?? "").trim();
@ -423,7 +549,8 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
activeAbort = new AbortController();
const abort = new AbortController();
activeAborts.add(abort);
let full = "";
let thinking = "";
const callbacks = {
@ -463,6 +590,27 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
);
},
onToolConfirm(name: string, args: Record<string, unknown>) {
return confirmTool(assistantRow.id, name, args);
},
async onStats(stats: ChatStats) {
// Nur bei Ollama kennt der Server das echte Fenster; bei OpenRouter
// steht die Kontextlänge in der Modellliste, die der Client schon hat.
const contextLength =
opts.provider === "ollama"
? await effectiveContextLength(opts.model)
: undefined;
socket.send(
JSON.stringify({
type: "stats",
messageId: assistantRow.id,
model: opts.provider === "ollama" ? opts.model : opts.openrouterModel,
provider: opts.provider,
contextLength,
...stats,
}),
);
},
};
try {
@ -479,10 +627,10 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
},
apiKey,
callbacks,
activeAbort.signal,
abort.signal,
);
} else {
await streamChat(history, system, opts, callbacks, activeAbort.signal);
await streamChat(history, system, opts, callbacks, abort.signal);
}
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
mem.appendEvent(session.id, "message", {
@ -493,7 +641,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({
type: "done",
messageId: assistantRow.id,
aborted: activeAbort.signal.aborted,
aborted: abort.signal.aborted,
}),
);
broadcast({ type: "sessions-changed" });
@ -507,7 +655,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
}
} catch (err) {
const aborted =
activeAbort.signal.aborted ||
abort.signal.aborted ||
(err instanceof Error && err.name === "AbortError");
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
if (aborted) {
@ -522,7 +670,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
);
}
} finally {
activeAbort = null;
activeAborts.delete(abort);
}
});
});
@ -549,4 +697,9 @@ server.on("upgrade", (req, socket, head) => {
app.listen({ port: PORT, host: "127.0.0.1" }, () => {
console.log(`[agenttwo-tools] Server läuft auf http://127.0.0.1:${PORT}`);
console.log(
hasBuild
? "[agenttwo-tools] Frontend aus web/dist wird mit ausgeliefert"
: "[agenttwo-tools] Kein web/dist — Frontend über 'npm run dev:web' (:5174)",
);
});

View file

@ -16,6 +16,24 @@ export interface OllamaOptions {
sessionId?: string;
}
/**
* Messwerte einer Antwort. Zahlen kommen aus dem Abschluss-Chunk von Ollama
* bzw. dem usage-Block von OpenRouter nicht geschätzt.
*/
export interface ChatStats {
/** Tokens im Prompt der letzten Runde: das, was zuletzt im Kontext lag. */
promptTokens: number;
/** Erzeugte Tokens, über alle Werkzeugrunden summiert. */
responseTokens: number;
/** Bis zum ersten sichtbaren Token (Denken zählt mit). null, wenn keins kam. */
ttftMs: number | null;
/** Reine Generierungszeit, ohne Prompt-Auswertung und Modell-Laden. */
evalMs: number;
/** Wanduhr über alles, inklusive Werkzeuglaufzeit. */
totalMs: number;
rounds: number;
}
export interface StreamCallbacks {
onThinking(text: string): void;
onToken(text: string): void;
@ -24,6 +42,16 @@ export interface StreamCallbacks {
onToolCall?(name: string, args: Record<string, unknown>): void;
/** Werkzeug ist fertig. */
onToolResult?(name: string, ok: boolean, durationMs: number): void;
/**
* Holt die Freigabe des Nutzers für ein bestätigungspflichtiges Werkzeug.
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
*/
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
/**
* Messwerte, sobald die Antwort steht. Darf asynchron sein der Aufrufer
* wartet ab, damit die Zahlen sicher vor `done` beim Client sind.
*/
onStats?(stats: ChatStats): void | Promise<void>;
}
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
@ -42,8 +70,22 @@ interface ChatChunk {
};
done?: boolean;
error?: string;
/** Nur im Abschluss-Chunk. Dauern in Nanosekunden. */
prompt_eval_count?: number;
eval_count?: number;
eval_duration?: number;
}
/** Rohwerte einer Runde, wie Ollama sie meldet. */
interface RoundStats {
promptTokens: number;
responseTokens: number;
evalMs: number;
ttftMs: number | null;
}
const NS_PER_MS = 1e6;
export interface ChatMessage {
role: string;
content: string;
@ -90,7 +132,23 @@ async function streamOnce(
opts: OllamaOptions,
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<{ toolCalls: ToolCall[]; content: string }> {
): Promise<{ toolCalls: ToolCall[]; content: string; stats: RoundStats }> {
const startedAt = Date.now();
let firstTokenAt: number | null = null;
const stats: RoundStats = {
promptTokens: 0,
responseTokens: 0,
evalMs: 0,
ttftMs: null,
};
/** Übernimmt die Zahlen aus dem Abschluss-Chunk. */
function collect(chunk: ChatChunk) {
stats.promptTokens = chunk.prompt_eval_count ?? 0;
stats.responseTokens = chunk.eval_count ?? 0;
stats.evalMs = Math.round((chunk.eval_duration ?? 0) / NS_PER_MS);
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
}
const body: Record<string, unknown> = {
model: opts.model,
messages,
@ -139,6 +197,9 @@ async function streamOnce(
continue;
}
if (chunk.error) throw new Error(chunk.error);
if (chunk.message?.thinking || chunk.message?.content) {
firstTokenAt ??= Date.now();
}
if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking);
if (chunk.message?.content) {
content += chunk.message.content;
@ -150,10 +211,13 @@ async function streamOnce(
toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) });
}
}
if (chunk.done) return { toolCalls, content };
if (chunk.done) {
collect(chunk);
return { toolCalls, content, stats };
}
}
}
return { toolCalls, content };
return { toolCalls, content, stats };
}
/**
@ -176,15 +240,40 @@ export async function streamChat(
...history.map(toWire),
];
const startedAt = Date.now();
const total: ChatStats = {
promptTokens: 0,
responseTokens: 0,
ttftMs: null,
evalMs: 0,
totalMs: 0,
rounds: 0,
};
/**
* Werkzeugrunden sind mehrere Ollama-Aufrufe für eine sichtbare Antwort:
* Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde
* (die größte Belegung), TTFT zählt nur die erste Runde.
*/
function fold(round: RoundStats) {
total.rounds++;
total.promptTokens = round.promptTokens || total.promptTokens;
total.responseTokens += round.responseTokens;
total.evalMs += round.evalMs;
total.ttftMs ??= round.ttftMs;
total.totalMs = Date.now() - startedAt;
}
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
const lastRound = round === MAX_TOOL_ROUNDS;
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
// statt eines weiteren Aufrufwunsches.
const roundOpts = lastRound ? { ...opts, tools: false } : opts;
const { toolCalls, content } = await streamOnce(messages, roundOpts, cb, signal);
const { toolCalls, content, stats } = await streamOnce(messages, roundOpts, cb, signal);
fold(stats);
if (toolCalls.length === 0) {
await cb.onStats?.(total);
cb.onDone();
return;
}
@ -200,11 +289,18 @@ export async function streamChat(
for (const call of toolCalls) {
cb.onToolCall?.(call.name, call.arguments);
const result = await runTool(call, { signal, sessionId: opts.sessionId });
const result = await runTool(call, {
signal,
sessionId: opts.sessionId,
confirm: cb.onToolConfirm
? (c) => cb.onToolConfirm!(c.name, c.arguments)
: undefined,
});
cb.onToolResult?.(result.name, result.ok, result.durationMs);
messages.push({ role: "tool", tool_name: result.name, content: result.content });
}
}
await cb.onStats?.(total);
cb.onDone();
}

View file

@ -1,4 +1,5 @@
import { mimeFromBase64 } from "./images.js";
import type { ChatStats } from "./ollama.js";
export interface OpenRouterOptions {
model: string;
@ -10,6 +11,8 @@ export interface StreamCallbacks {
onThinking(text: string): void;
onToken(text: string): void;
onDone(): void;
/** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */
onStats?(stats: ChatStats): void | Promise<void>;
}
export function getOpenRouterKey(): string | undefined {
@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined {
}
export async function listOpenRouterModels(): Promise<
{ id: string; name: string; contextLength: number; promptPrice: number }[]
{
id: string;
name: string;
contextLength: number;
promptPrice: number;
completionPrice: number;
}[]
> {
const res = await fetch("https://openrouter.ai/api/v1/models");
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise<
id: string;
name: string;
context_length: number;
pricing: { prompt: string };
pricing: { prompt: string; completion?: string };
}[];
};
// Preise kommen pro Token; die Anzeige rechnet in Preis je 1 Mio. Tokens.
return data.data
.map((m) => ({
id: m.id,
name: m.name,
contextLength: m.context_length,
promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000,
completionPrice: Number(m.pricing?.completion ?? 0) * 1_000_000,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
@ -65,6 +76,10 @@ export async function streamOpenRouter(
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<void> {
// Vor dem fetch: TTFT soll die Wartezeit auf den Anbieter enthalten, nicht
// erst ab dem Eintreffen der Antwort-Header zählen (so misst es auch Ollama).
const startedAt = Date.now();
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
@ -76,6 +91,8 @@ export async function streamOpenRouter(
body: JSON.stringify({
model: opts.model,
stream: true,
// Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer.
stream_options: { include_usage: true },
messages: [
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
...history.map(toOpenAIMessage),
@ -95,6 +112,27 @@ export async function streamOpenRouter(
const decoder = new TextDecoder();
let buffer = "";
let firstTokenAt: number | null = null;
const stats: ChatStats = {
promptTokens: 0,
responseTokens: 0,
ttftMs: null,
evalMs: 0,
totalMs: 0,
rounds: 1,
};
/**
* OpenRouter meldet keine reine Generierungszeit. Als evalMs zählt deshalb
* die Zeit ab dem ersten Token das ist die Spanne, über die tok/s
* überhaupt aussagekräftig ist.
*/
function finish(): void | Promise<void> {
stats.totalMs = Date.now() - startedAt;
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
return cb.onStats?.(stats);
}
while (true) {
const { value, done } = await reader.read();
if (done) break;
@ -107,6 +145,7 @@ export async function streamOpenRouter(
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") {
await finish();
cb.onDone();
return;
}
@ -116,6 +155,7 @@ export async function streamOpenRouter(
finish_reason?: string | null;
}[];
error?: { message?: string };
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
try {
chunk = JSON.parse(payload);
@ -123,14 +163,24 @@ export async function streamOpenRouter(
continue;
}
if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error");
if (chunk.usage) {
stats.promptTokens = chunk.usage.prompt_tokens ?? 0;
stats.responseTokens = chunk.usage.completion_tokens ?? 0;
}
const delta = chunk.choices?.[0]?.delta;
if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now();
if (delta?.reasoning) cb.onThinking(delta.reasoning);
if (delta?.content) cb.onToken(delta.content);
if (chunk.choices?.[0]?.finish_reason && !delta?.content) {
// Der usage-Block kommt erst nach dem finish_reason-Chunk. Nur wenn er
// schon da ist, darf hier abgekürzt werden — sonst bis [DONE] weiterlesen
// und die Tokenzahlen mitnehmen.
if (chunk.choices?.[0]?.finish_reason && !delta?.content && stats.promptTokens) {
await finish();
cb.onDone();
return;
}
}
}
await finish();
cb.onDone();
}

View file

@ -39,6 +39,9 @@ interface Bucket {
resetAt: number;
}
/** Ab dieser Größe wird aufgeräumt — reicht für jede realistische Nutzung. */
const MAX_BUCKETS = 1000;
/**
* Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte
* (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten.
@ -46,8 +49,16 @@ interface Bucket {
export function createRateLimiter(limit: number, windowMs: number) {
const buckets = new Map<string, Bucket>();
/** Abgelaufene Zähler wegräumen, damit die Map nicht unbegrenzt wächst. */
function sweep(now: number) {
for (const [key, bucket] of buckets) {
if (now >= bucket.resetAt) buckets.delete(key);
}
}
return function allow(key: string): boolean {
const now = Date.now();
if (buckets.size > MAX_BUCKETS) sweep(now);
const bucket = buckets.get(key);
if (!bucket || now >= bucket.resetAt) {

View file

@ -43,6 +43,11 @@ export function toolNames(): string[] {
return REGISTRY.map((t) => t.name);
}
/**
* Zeitlimit für das Ergebnis. Der `signal` unten bricht das Werkzeug zusätzlich
* ab beides zusammen, weil ein Werkzeug den Signal auch ignorieren kann und
* die Antwort dann trotzdem nicht ewig hängen darf.
*/
function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
@ -74,9 +79,26 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
};
}
if (tool.requiresConfirmation && !(await isApproved(call, ctx))) {
return {
name: tool.name,
content: JSON.stringify({
error:
"Vom Nutzer abgelehnt. Nicht erneut aufrufen — ohne dieses Werkzeug " +
"weitermachen und sagen, was dadurch fehlt.",
}),
ok: false,
durationMs: Date.now() - started,
};
}
// Zeitlimit und Nutzer-Abbruch als ein Signal, das an das Werkzeug geht:
// damit endet auch ein laufender Netzwerkabruf, statt weiterzulaufen.
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TOOL_TIMEOUT_MS)]);
try {
const value = await withTimeout(
tool.run(call.arguments, ctx),
tool.run(call.arguments, { ...ctx, signal }),
TOOL_TIMEOUT_MS,
tool.name,
);
@ -98,3 +120,17 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
};
}
}
/**
* Freigabe für ein bestätigungspflichtiges Werkzeug. Ohne Rückkanal (Skripte,
* Tests) gilt "abgelehnt" die Bestätigung soll sich nicht dadurch umgehen
* lassen, dass niemand zum Fragen da ist.
*/
async function isApproved(call: ToolCall, ctx: ToolContext): Promise<boolean> {
if (!ctx.confirm) return false;
try {
return await ctx.confirm(call);
} catch {
return false;
}
}

View file

@ -6,7 +6,7 @@ import type { Tool } from "./types.js";
export const rememberTool: Tool = {
name: "remember",
description:
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte.",
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte. Der Nutzer muss jeden Aufruf freigeben.",
requiresConfirmation: true,
parameters: {
type: "object",
@ -27,6 +27,10 @@ export const rememberTool: Tool = {
const text = String(args.text ?? "").trim();
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
if (text.length < 6) throw new ToolError("Text ist zu kurz, um ihn zu merken");
// Bewusst ungepinnt: ein vom Modell gesetzter Anker soll dem normalen
// Verfall unterliegen. Gepinnt wird nur, was der Nutzer im
// Gedächtnis-Panel selbst mit ★ markiert — sonst überlebt ein einmal
// untergeschobener "Fakt" jede Traumphase und jede Rekonstruktion.
const result = upsertAnchor(ctx.sessionId, {
text,
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
@ -34,7 +38,6 @@ export const rememberTool: Tool = {
: "fact",
importance: 0.9,
origin: "model",
pinned: true,
});
return { stored: result, text };
},

View file

@ -10,6 +10,12 @@ export interface ToolContext {
signal: AbortSignal;
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
sessionId?: string;
/**
* Holt die Freigabe des Nutzers für ein Werkzeug mit Außenwirkung.
* Fehlt der Rückkanal, werden bestätigungspflichtige Werkzeuge abgelehnt
* lieber nicht ausführen als ungefragt.
*/
confirm?(call: ToolCall): Promise<boolean>;
}
export interface Tool {
@ -17,9 +23,9 @@ export interface Tool {
description: string;
parameters: ToolSchema;
/**
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server).
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss.
* Kennzeichnet Werkzeuge mit Außenwirkung (Netzwerk, dauerhafter Speicher).
* `runTool` fragt vor der Ausführung über `ToolContext.confirm` beim Nutzer
* nach und lehnt ab, wenn keine Freigabe kommt.
*/
requiresConfirmation?: boolean;
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */

View file

@ -1,5 +1,8 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import dns from "node:dns";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP, type LookupFunction } from "node:net";
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
import { parseHTML } from "linkedom";
import { Defuddle } from "defuddle/node";
import { ToolError } from "./types.js";
@ -9,6 +12,8 @@ const TIMEOUT_MS = 15_000;
const MAX_HTML_BYTES = 2 * 1024 * 1024;
const MAX_CONTENT_CHARS = 25_000;
const MAX_REDIRECTS = 5;
const ALLOWED_TYPES =
/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/;
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 agenttwo-readweb/1.0";
@ -39,23 +44,53 @@ function isPrivateIP(ip: string): boolean {
first.startsWith("fe9") || first.startsWith("fea") || first.startsWith("feb");
}
/** `new URL().hostname` liefert IPv6-Literale in Klammern: [::1] -> ::1. */
function bareHost(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]")
? hostname.slice(1, -1)
: hostname;
}
/**
* SSRF-Schutz: Der Server löst den Host selbst auf und weist private Bereiche
* ab. Ohne das könnte das Modell http://localhost:8788/api/sessions lesen —
* der Origin-Check schützt nicht vor server-eigenem fetch.
* DNS-Auflösung, die private Adressen ablehnt eingehängt als `lookup` der
* HTTP-Verbindung.
*
* Entscheidend ist, dass genau diese Auflösung auch verbunden wird. Ein
* getrennter Vorab-Check (wie ihn `fetch` erzwingt, das selbst noch einmal
* auflöst) ließe DNS-Rebinding zu: öffentlich bei der Prüfung, 127.0.0.1 beim
* Verbinden.
*/
const guardedLookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err, "", 0);
const addresses = Array.isArray(address) ? address : [{ address, family }];
for (const a of addresses) {
if (isPrivateIP(a.address)) {
return callback(new ToolError("Zugriff auf private Adressen ist gesperrt"), "", 0);
}
}
callback(null, address as string, family);
});
};
/**
* Vorab-Prüfung, rein für die Fehlermeldung: so bekommt das Modell "private
* Adresse gesperrt" statt eines generischen Verbindungsfehlers. Die
* verbindliche Grenze ist `guardedLookup`.
*/
async function assertPublicHost(hostname: string): Promise<void> {
if (isIP(hostname)) {
if (isPrivateIP(hostname)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
const host = bareHost(hostname);
if (isIP(host)) {
if (isPrivateIP(host)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
return;
}
let addrs: { address: string }[];
try {
addrs = await lookup(hostname, { all: true, verbatim: true });
addrs = await dns.promises.lookup(host, { all: true, verbatim: true });
} catch {
throw new ToolError(`Host nicht auflösbar: ${hostname}`);
throw new ToolError(`Host nicht auflösbar: ${host}`);
}
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${hostname}`);
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${host}`);
for (const a of addrs) {
if (isPrivateIP(a.address)) {
throw new ToolError("Zugriff auf private Adressen ist gesperrt");
@ -76,49 +111,93 @@ function assertHttpUrl(raw: string): URL {
return url;
}
async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: string }> {
let url = assertHttpUrl(rawUrl).toString();
/** Ein GET mit gepinnter Auflösung. Weiterleitungen bleiben Sache des Aufrufers. */
function send(url: URL, signal: AbortSignal): Promise<IncomingMessage> {
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const req = request(
url,
{
method: "GET",
lookup: guardedLookup,
signal,
timeout: TIMEOUT_MS,
headers: {
"User-Agent": USER_AGENT,
Accept: "text/html, text/plain, application/xhtml+xml",
"Accept-Encoding": "gzip, deflate, br",
},
},
resolve,
);
req.on("timeout", () => req.destroy(new ToolError("Zeitlimit beim Abruf überschritten")));
req.on("error", (err) => {
if (err instanceof ToolError) return reject(err);
if (signal.aborted) return reject(new ToolError("Abruf abgebrochen"));
reject(new ToolError(`Abruf fehlgeschlagen: ${url.host}`));
});
req.end();
});
}
/** Antwortkörper bis MAX_HTML_BYTES lesen, komprimierte Antworten auspacken. */
async function readCapped(res: IncomingMessage): Promise<string> {
const encoding = String(res.headers["content-encoding"] ?? "").toLowerCase();
const stream =
encoding === "gzip" ? res.pipe(createGunzip())
: encoding === "deflate" ? res.pipe(createInflate())
: encoding === "br" ? res.pipe(createBrotliDecompress())
: res;
const decoder = new TextDecoder();
let html = "";
let bytes = 0;
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytes += chunk.byteLength;
html += decoder.decode(chunk, { stream: true });
if (bytes > MAX_HTML_BYTES) break;
}
} catch {
// Abbruch mitten im Strom: was schon da ist, reicht dem Extraktor meist.
if (!html) throw new ToolError("Antwort konnte nicht gelesen werden");
} finally {
res.destroy();
}
return html;
}
async function fetchWithGuards(
rawUrl: string,
signal: AbortSignal,
): Promise<{ url: string; body: string }> {
let url = assertHttpUrl(rawUrl);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertPublicHost(new URL(url).hostname);
await assertPublicHost(url.hostname);
const res = await fetch(url, {
redirect: "manual",
headers: { "User-Agent": USER_AGENT, Accept: "text/html, text/plain, application/xhtml+xml" },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const res = await send(url, signal);
const status = res.statusCode ?? 0;
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) break;
url = new URL(location, url).toString();
assertHttpUrl(url);
if (status >= 300 && status < 400) {
const location = res.headers.location;
res.destroy();
if (!location) throw new ToolError(`Weiterleitung ohne Ziel (HTTP ${status})`);
url = assertHttpUrl(new URL(location, url).toString());
continue;
}
if (!res.ok) throw new ToolError(`HTTP ${res.status} für ${url}`);
if (status < 200 || status >= 300) {
res.destroy();
throw new ToolError(`HTTP ${status} für ${url}`);
}
const type = (res.headers.get("content-type") ?? "").toLowerCase();
if (!/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/.test(type)) {
const type = String(res.headers["content-type"] ?? "").toLowerCase();
if (!ALLOWED_TYPES.test(type)) {
res.destroy();
throw new ToolError(`Nicht unterstützter Inhaltstyp: ${type || "unbekannt"}`);
}
const reader = res.body?.getReader();
if (!reader) throw new ToolError("Leere Antwort");
const decoder = new TextDecoder();
let html = "";
let bytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_HTML_BYTES) {
void reader.cancel();
html += decoder.decode(value, { stream: true });
break;
}
html += decoder.decode(value, { stream: true });
}
return { url, body: html };
return { url: url.toString(), body: await readCapped(res) };
}
throw new ToolError(`Zu viele Weiterleitungen (> ${MAX_REDIRECTS})`);
}
@ -126,7 +205,10 @@ async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: str
export const readWebpageTool: Tool = {
name: "read_webpage",
description:
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen.",
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen. Der Nutzer muss jeden Aufruf freigeben.",
// Der Abruf verlässt den Rechner: die URL selbst ist ein Kanal nach außen.
// Deshalb sieht der Nutzer sie vor dem Aufruf und gibt sie frei.
requiresConfirmation: true,
parameters: {
type: "object",
properties: {
@ -134,11 +216,11 @@ export const readWebpageTool: Tool = {
},
required: ["url"],
},
async run(args) {
async run(args, ctx) {
const raw = String(args.url ?? "").trim();
if (!raw) throw new ToolError("url fehlt");
const { url, body } = await fetchWithGuards(raw);
const { url, body } = await fetchWithGuards(raw, ctx.signal);
const { document } = parseHTML(body);
const result = await Defuddle(document, url, { markdown: true });