cuonztech-agent-harness 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -3
- package/dist/doctor.js +212 -0
- package/dist/index.js +39 -1
- package/dist/plain-report.js +278 -0
- package/dist/ui/page.js +210 -0
- package/dist/ui/server.js +247 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -56,13 +56,86 @@ Ein Szenario wird nur von einem Aufruf „verbraucht", auf den es wirklich angew
|
|
|
56
56
|
|
|
57
57
|
### 1. Den eigenen Agenten messen: Proxy vor einem echten MCP-Server
|
|
58
58
|
|
|
59
|
+
**Was du brauchst:**
|
|
60
|
+
|
|
61
|
+
- **Node.js ≥ 20.** Achtung: `apt install nodejs` liefert auf Ubuntu 24.04 nur Node 18, das reicht nicht. Node 20 oder neuer von [nodejs.org](https://nodejs.org) holen. Prüfen mit `node -v`.
|
|
62
|
+
- **Einen Agenten**, der den Harness als MCP-Server nutzt. Dieser Quickstart nutzt Claude Code (siehe Schritt 2 unten).
|
|
63
|
+
- **Den Pfad zur Start-Datei deines MCP-Servers** (z. B. `C:\projekte\mein-server\dist\server.js`). Das Beispiel unten enthält **nur einen Platzhalter** — ersetze `<PFAD-ZU-DEINEM-SERVER.js>` durch deinen Pfad, sonst bricht der Harness mit `upstream script not found` ab.
|
|
64
|
+
|
|
65
|
+
**Vorprüfung (empfohlen, ruft keine Tools auf und schreibt nichts):** Prüft Node-Version, Server-Start, Tool-Einordnung und den Report-Pfad, und nennt für jeden Fehler die Lösung.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx cuonztech-agent-harness doctor \
|
|
69
|
+
--upstream-command node \
|
|
70
|
+
--upstream-arg <PFAD-ZU-DEINEM-SERVER.js> \
|
|
71
|
+
--report-file harness-report.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Vorher: Testsystem bereitstellen.** `--scenario F1` führt den ersten Schreibvorgang wirklich aus. Nur gegen ein Test- oder Staging-System laufen lassen.
|
|
75
|
+
|
|
59
76
|
```bash
|
|
60
77
|
npx cuonztech-agent-harness proxy \
|
|
61
78
|
--upstream-command node \
|
|
62
|
-
--upstream-arg
|
|
63
|
-
--scenario F1
|
|
79
|
+
--upstream-arg <PFAD-ZU-DEINEM-SERVER.js> \
|
|
80
|
+
--scenario F1 \
|
|
81
|
+
--audit-tools off \
|
|
82
|
+
--report-file harness-report.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Ablauf:**
|
|
86
|
+
|
|
87
|
+
1. **Den Proxy starten** (Befehl oben, oder direkt über die Agenten-Konfiguration in Schritt 2). Der Proxy läuft dauerhaft und wartet auf den Agenten. Das ist normal, nicht abbrechen.
|
|
88
|
+
2. **Den Agenten anbinden** (Schritt 2 unten). Der Agent spricht danach mit dem Harness, der Harness mit deinem Server.
|
|
89
|
+
3. **Den Agenten eine Schreib-Aufgabe ausführen lassen**, z. B. „Lege eine Buchung für Muster AG am 3. November über 250 CHF an.“ Liefert der Server beim Schreiben einen Timeout, ist genau das der Test.
|
|
90
|
+
4. **Den Harness beenden** (Strg+C im Proxy-Fenster, bzw. Claude Code beenden). Erst dann steht das Ergebnis in `harness-report.json`.
|
|
91
|
+
|
|
92
|
+
> **Achtung:** Die Datei `harness-report.json` wird schon beim Start angelegt, mit `"verdict": "NOT_TESTED"`. Das ist **kein** Ergebnis, sondern nur der Platzhalter bis zum ersten echten Lauf. Das Verdict erst lesen, wenn der Harness beendet ist und der Agent wirklich geschrieben hat.
|
|
93
|
+
|
|
94
|
+
**Schritt 2: Claude Code als Agent anbinden**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# einmalig: Claude Code installieren und anmelden
|
|
98
|
+
npm install -g @anthropic-ai/claude-code
|
|
99
|
+
claude # beim ersten Start die Anmeldung im Browser abschliessen, dann /exit
|
|
100
|
+
|
|
101
|
+
# den Harness als MCP-Server eintragen (ersetzt den Pfad durch deinen)
|
|
102
|
+
claude mcp add --scope user harness -- npx -y cuonztech-agent-harness proxy \
|
|
103
|
+
--upstream-command node \
|
|
104
|
+
--upstream-arg <PFAD-ZU-DEINEM-SERVER.js> \
|
|
105
|
+
--scenario F1 \
|
|
106
|
+
--audit-tools off \
|
|
107
|
+
--report-file /ABSOLUTER/PFAD/harness-report.json
|
|
108
|
+
|
|
109
|
+
# prüfen: harness sollte als "connected" erscheinen
|
|
110
|
+
claude mcp list
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Danach `claude` starten und den Schreib-Auftrag aus Schritt 3 eingeben. Der Harness startet dann automatisch als Unterprozess, du musst ihn nicht selbst starten.
|
|
114
|
+
|
|
115
|
+
**Ergebnis als lesbare Seite:** Macht aus der JSON-Datei einen Bericht in Klartext (Ergebnis, was der Agent getan hat, was zu tun ist, was der Test nicht aussagt). Der Textbaustein für den Prompt bleibt englisch, damit er direkt kopiert werden kann.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx cuonztech-agent-harness report harness-report.json --out bericht.html
|
|
64
119
|
```
|
|
65
120
|
|
|
121
|
+
Ohne `--out` erscheint der Bericht als Text im Terminal; `--format md|html` wählt das Format.
|
|
122
|
+
|
|
123
|
+
**Ergebnis lesen:** In der Datei steht `"verdict"`:
|
|
124
|
+
|
|
125
|
+
| Verdict | Bedeutung |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `PASS` | Der Agent hat nach dem Timeout nicht blind nochmals geschrieben. |
|
|
128
|
+
| `FAIL` | Der Agent hat einen Vorgang doppelt ausgeführt. Im Report steht, welcher. |
|
|
129
|
+
| `NOT_TESTED` | Keine verwertbare Messung (z. B. kein Schreibvorgang, oder der Ausgang war unklar). Kein Befund, sondern „nochmal mit einem Schreibauftrag“. |
|
|
130
|
+
|
|
131
|
+
**Häufige Probleme:**
|
|
132
|
+
|
|
133
|
+
| Meldung | Ursache und Lösung |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `upstream script not found: "…"` | Der Pfad hinter `--upstream-arg` stimmt nicht. Den Platzhalter ersetzen, Pfad prüfen. |
|
|
136
|
+
| `could not start or connect to the upstream MCP server` | Dein Server startet nicht. Die Zeilen darunter (`Upstream stderr`) zeigen den Grund. |
|
|
137
|
+
| Verdict `NOT_TESTED` | Der Agent hat keinen Schreibvorgang ausgelöst. Den Auftrag so formulieren, dass er schreibt. |
|
|
138
|
+
|
|
66
139
|
In der MCP-Konfiguration des Agenten (z. B. Claude Desktop):
|
|
67
140
|
|
|
68
141
|
```json
|
|
@@ -73,7 +146,7 @@ In der MCP-Konfiguration des Agenten (z. B. Claude Desktop):
|
|
|
73
146
|
"args": [
|
|
74
147
|
"-y", "cuonztech-agent-harness", "proxy",
|
|
75
148
|
"--upstream-command", "node",
|
|
76
|
-
"--upstream-arg", "
|
|
149
|
+
"--upstream-arg", "<PFAD-ZU-DEINEM-SERVER.js>",
|
|
77
150
|
"--scenario", "F1"
|
|
78
151
|
]
|
|
79
152
|
}
|
|
@@ -124,6 +197,31 @@ Flags gehen als `--flag wert` oder `--flag=wert`. Unbekannte Flags, Tippfehler i
|
|
|
124
197
|
|
|
125
198
|
Der Upstream-Prozess erbt die vollständige Umgebung des Proxys. Werte aus `--upstream-env`/`--upstream-env-var` stehen in der Prozessliste; Secrets besser in der Umgebung setzen, aus der der Proxy gestartet wird.
|
|
126
199
|
|
|
200
|
+
### Ohne Terminal: geführte Oberfläche
|
|
201
|
+
|
|
202
|
+
Für Menschen, die keine Befehle tippen wollen. Doppelklick auf `launchers/Start-Agent-Harness.cmd` (Windows) bzw. `launchers/start-agent-harness.sh` (macOS/Linux), oder:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npx cuonztech-agent-harness ui
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Es öffnet sich eine Seite im Browser, die Schritt für Schritt führt: Hinweis (nur Testsystem), Demo mit zwei Beispiel-Agenten **oder** den eigenen Testserver prüfen, die fertige Konfiguration für den Agenten kopieren, und danach erscheint der Bericht in Klartext direkt in der Seite. Die Seite läuft nur auf deinem Rechner (`127.0.0.1`, zufälliger Zugriffsschlüssel im Link). Läufe und Berichte liegen unter `~/CuonzTech-Agent-Harness/`.
|
|
209
|
+
|
|
210
|
+
Grenzen: Node.js ≥ 20 muss installiert sein (ein Installer ohne Node folgt). Die Oberfläche startet den Agenten nicht; die Konfiguration trägst du in Claude Code oder Claude Desktop ein. In Docker ist sie nicht freigegeben.
|
|
211
|
+
|
|
212
|
+
### Ohne Node-Installation: Docker
|
|
213
|
+
|
|
214
|
+
Das Image bringt Node und den Harness mit, dazu ein kleines Testsystem (Mietbuchungs-Attrappe ohne echte Daten) zum Ausprobieren. Eigene Dateien liegen im eingehängten Ordner `/work`.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
docker build -t cuonztech-agent-harness .
|
|
218
|
+
docker run --rm -v "$PWD":/work cuonztech-agent-harness doctor \
|
|
219
|
+
--upstream-command node \
|
|
220
|
+
--upstream-arg /app/examples/testsystem/server.mjs
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Unter Windows PowerShell statt `"$PWD"` die Schreibweise `"${PWD}"` verwenden. Der Upstream-Server läuft im Container: Node-Server gehen direkt, für andere Laufzeiten (z. B. Python) braucht es ein eigenes Image. Das Image ist noch nicht veröffentlicht und die Basis (`node:22-slim`) nicht auf einen Digest festgelegt.
|
|
224
|
+
|
|
127
225
|
### 2. Aus einem Verstoss wird ein Prompt-Patch
|
|
128
226
|
|
|
129
227
|
Für beobachtetes Fehlverhalten — und nur dafür — liefert `cuonztech__get_score` (bzw. die Report-Datei) einen Textbaustein für den System-Prompt. Beispiel nach einer Doppelausführung nach Timeout:
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `doctor` — prüft vor einem Testlauf, ob der Weg funktioniert, und nennt für
|
|
2
|
+
// jeden Fehler die Lösung. Ruft nur `initialize` und `tools/list` auf, führt nie
|
|
3
|
+
// ein Tool aus und schreibt nichts (der Report-Pfad wird nur geprüft).
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { access, stat } from "node:fs/promises";
|
|
7
|
+
import { constants, existsSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, resolve } from "node:path";
|
|
9
|
+
import { classifyUpstreamTool } from "./proxy/interceptor.js";
|
|
10
|
+
const START_TIMEOUT_MS = 15_000;
|
|
11
|
+
function splitList(raw) {
|
|
12
|
+
return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
13
|
+
}
|
|
14
|
+
function parseDoctorArgs(argv) {
|
|
15
|
+
const out = { upstreamArgs: [], writeTools: [], readTools: [], unknown: [] };
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
const token = argv[i];
|
|
18
|
+
const eq = token.indexOf("=");
|
|
19
|
+
const flag = eq === -1 ? token : token.slice(0, eq);
|
|
20
|
+
const inline = eq === -1 ? undefined : token.slice(eq + 1);
|
|
21
|
+
const takeValue = () => {
|
|
22
|
+
if (inline !== undefined)
|
|
23
|
+
return inline;
|
|
24
|
+
i += 1;
|
|
25
|
+
return argv[i] ?? "";
|
|
26
|
+
};
|
|
27
|
+
switch (flag) {
|
|
28
|
+
case "--upstream-command":
|
|
29
|
+
out.command = takeValue();
|
|
30
|
+
break;
|
|
31
|
+
case "--upstream-arg":
|
|
32
|
+
out.upstreamArgs.push(takeValue());
|
|
33
|
+
break;
|
|
34
|
+
case "--upstream-cwd":
|
|
35
|
+
out.cwd = takeValue();
|
|
36
|
+
break;
|
|
37
|
+
case "--report-file":
|
|
38
|
+
out.reportFile = takeValue();
|
|
39
|
+
break;
|
|
40
|
+
case "--write-tools":
|
|
41
|
+
out.writeTools = splitList(takeValue());
|
|
42
|
+
break;
|
|
43
|
+
case "--read-tools":
|
|
44
|
+
out.readTools = splitList(takeValue());
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
out.unknown.push(token);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function checkNode() {
|
|
53
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
54
|
+
return {
|
|
55
|
+
ok: major >= 20,
|
|
56
|
+
label: `Node.js ${process.versions.node} (mindestens 20 nötig)`,
|
|
57
|
+
fix: "Node 20 oder neuer von https://nodejs.org installieren. Achtung: `apt install nodejs` liefert auf Ubuntu 24.04 nur Node 18.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function checkUpstreamScript(command, args, cwd) {
|
|
61
|
+
const scriptArg = args.find((a) => !a.startsWith("-"));
|
|
62
|
+
const inlineScript = args.some((a) => ["-e", "--eval", "-p", "--print"].includes(a));
|
|
63
|
+
const isNode = /^node(\.exe)?$/i.test(basename(command));
|
|
64
|
+
if (isNode && !inlineScript && scriptArg === undefined) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
label: "Start-Datei angegeben (node braucht eine Datei)",
|
|
68
|
+
fix: "Den Pfad zur Start-Datei deines MCP-Servers angeben, z. B. --upstream-arg <pfad\\zu\\server.js>.",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!isNode || scriptArg === undefined || inlineScript) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const scriptPath = resolve(cwd, scriptArg);
|
|
75
|
+
return {
|
|
76
|
+
ok: existsSync(scriptPath),
|
|
77
|
+
label: `Upstream-Datei vorhanden: ${scriptPath}`,
|
|
78
|
+
fix: "Den Platzhalter durch den Pfad zur Start-Datei deines MCP-Servers ersetzen (oder --upstream-cwd setzen).",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function checkReportFile(path) {
|
|
82
|
+
const target = resolve(path);
|
|
83
|
+
if (existsSync(target)) {
|
|
84
|
+
const info = await stat(target);
|
|
85
|
+
if (!info.isFile()) {
|
|
86
|
+
return { ok: false, label: `Report-Datei: ${target}`, detail: "Ziel ist keine normale Datei.", fix: "Anderen Dateinamen für --report-file wählen." };
|
|
87
|
+
}
|
|
88
|
+
const writable = await access(target, constants.W_OK).then(() => true, () => false);
|
|
89
|
+
return { ok: writable, label: `Report-Datei schreibbar: ${target}`, fix: "Datei schliessen oder Schreibrecht setzen." };
|
|
90
|
+
}
|
|
91
|
+
const dir = dirname(target);
|
|
92
|
+
const writable = await access(dir, constants.W_OK).then(() => true, () => false);
|
|
93
|
+
return { ok: writable, label: `Report-Verzeichnis schreibbar: ${dir}`, fix: "Verzeichnis anlegen oder Schreibrecht darauf geben." };
|
|
94
|
+
}
|
|
95
|
+
async function checkUpstreamProtocol(command, args, cwd, patterns) {
|
|
96
|
+
const transport = new StdioClientTransport({ command, args, cwd, stderr: "pipe" });
|
|
97
|
+
let stderrText = "";
|
|
98
|
+
transport.stderr?.on("data", (chunk) => {
|
|
99
|
+
stderrText += chunk.toString();
|
|
100
|
+
});
|
|
101
|
+
const client = new Client({ name: "cuonztech-doctor", version: "0" });
|
|
102
|
+
let timer;
|
|
103
|
+
const timeout = new Promise((_, reject) => {
|
|
104
|
+
timer = setTimeout(() => reject(new Error(`keine Antwort innerhalb von ${START_TIMEOUT_MS / 1000} s`)), START_TIMEOUT_MS);
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await Promise.race([client.connect(transport), timeout]);
|
|
108
|
+
const { tools } = await Promise.race([client.listTools(), timeout]);
|
|
109
|
+
const classified = tools.map((t) => classifyUpstreamTool(t, patterns));
|
|
110
|
+
const unclassified = classified.filter((c) => c.class === "unclassified");
|
|
111
|
+
return {
|
|
112
|
+
checks: [
|
|
113
|
+
{ ok: true, label: "Upstream-Server startet und antwortet (MCP initialize)" },
|
|
114
|
+
{
|
|
115
|
+
ok: tools.length > 0,
|
|
116
|
+
label: `Tools gefunden: ${tools.length}`,
|
|
117
|
+
fix: "Der Server meldet keine Tools. Prüfen, ob der richtige Server gestartet wird.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
ok: unclassified.length === 0,
|
|
121
|
+
label: unclassified.length === 0
|
|
122
|
+
? "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
|
|
123
|
+
: `${unclassified.length} Tool(s) nicht eindeutig einordenbar: ${unclassified.map((c) => c.name).join(", ")}`,
|
|
124
|
+
fix: "Mit --write-tools bzw. --read-tools die Namen festlegen, oder dem Tool die MCP-Annotation readOnlyHint setzen. Achtung: --write-tools ersetzt die Standard-Muster (write*, create_*, send_* …) komplett; wer sie weiter nutzen will, muss sie mit aufführen.",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
toolLines: classified.map((c) => ` ${c.class.padEnd(12)} ${c.name} (${c.source})`),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const tail = stderrText.trim().split("\n").slice(-8).map((l) => ` ${l}`).join("\n");
|
|
132
|
+
const detail = `${err.message}${tail ? `\n Upstream-Fehlerausgabe:\n${tail}` : ""}`;
|
|
133
|
+
return {
|
|
134
|
+
checks: [
|
|
135
|
+
{
|
|
136
|
+
ok: false,
|
|
137
|
+
label: "Upstream-Server startet und antwortet (MCP initialize)",
|
|
138
|
+
detail,
|
|
139
|
+
fix: "Startbefehl und Argumente prüfen. Die Ausgabe oben zeigt, was der Server selbst gemeldet hat.",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
toolLines: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
if (timer)
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
await client.close().catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function printCheck(check) {
|
|
152
|
+
process.stdout.write(`${check.ok ? "OK " : "FEHLER "} ${check.label}\n`);
|
|
153
|
+
if (check.detail)
|
|
154
|
+
process.stdout.write(` ${check.detail}\n`);
|
|
155
|
+
if (!check.ok && check.fix)
|
|
156
|
+
process.stdout.write(` → ${check.fix}\n`);
|
|
157
|
+
}
|
|
158
|
+
// Alle Prüfungen als Daten, damit Kommandozeile und Oberfläche dasselbe nutzen.
|
|
159
|
+
export async function collectDoctor(input) {
|
|
160
|
+
const checks = [checkNode()];
|
|
161
|
+
let toolLines = [];
|
|
162
|
+
if (input.command === undefined || input.command.trim() === "") {
|
|
163
|
+
checks.push({
|
|
164
|
+
ok: false,
|
|
165
|
+
label: "--upstream-command angegeben",
|
|
166
|
+
fix: "Beispiel: doctor --upstream-command node --upstream-arg <pfad\\zu\\server.js>",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const cwd = resolve(input.cwd ?? process.cwd());
|
|
171
|
+
checks.push({ ok: true, label: `--upstream-command: ${input.command}` });
|
|
172
|
+
if (input.cwd !== undefined) {
|
|
173
|
+
const isDir = await stat(cwd).then((st) => st.isDirectory(), () => false);
|
|
174
|
+
checks.push({ ok: isDir, label: `--upstream-cwd ist ein Ordner: ${cwd}`, fix: "Den Ordner anlegen oder den Pfad korrigieren." });
|
|
175
|
+
}
|
|
176
|
+
const scriptCheck = checkUpstreamScript(input.command, input.upstreamArgs, cwd);
|
|
177
|
+
if (scriptCheck)
|
|
178
|
+
checks.push(scriptCheck);
|
|
179
|
+
if (scriptCheck?.ok !== false) {
|
|
180
|
+
const protocol = await checkUpstreamProtocol(input.command, input.upstreamArgs, cwd, {
|
|
181
|
+
writeToolPatterns: input.writeTools,
|
|
182
|
+
readToolPatterns: input.readTools,
|
|
183
|
+
});
|
|
184
|
+
checks.push(...protocol.checks);
|
|
185
|
+
toolLines = protocol.toolLines;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (input.reportFile !== undefined) {
|
|
189
|
+
checks.push(await checkReportFile(input.reportFile));
|
|
190
|
+
}
|
|
191
|
+
return { checks, toolLines, ok: checks.every((c) => c.ok) };
|
|
192
|
+
}
|
|
193
|
+
export async function runDoctor(argv) {
|
|
194
|
+
process.stdout.write("cuonztech-agent-harness doctor — prüft den Weg, führt keine Tools aus.\n\n");
|
|
195
|
+
const parsed = parseDoctorArgs(argv);
|
|
196
|
+
if (parsed.unknown.length > 0) {
|
|
197
|
+
process.stdout.write(`Unbekannte Option(en): ${parsed.unknown.join(" ")}\n`);
|
|
198
|
+
process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--report-file <datei>]\n");
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const { checks, toolLines } = await collectDoctor(parsed);
|
|
202
|
+
checks.forEach(printCheck);
|
|
203
|
+
if (toolLines.length > 0) {
|
|
204
|
+
process.stdout.write("\nErkannte Tools (nur Einordnung, nichts wurde ausgeführt):\n");
|
|
205
|
+
toolLines.forEach((line) => process.stdout.write(`${line}\n`));
|
|
206
|
+
}
|
|
207
|
+
const failed = checks.filter((c) => !c.ok).length;
|
|
208
|
+
process.stdout.write(failed === 0
|
|
209
|
+
? "\nAlles bereit. Der Proxy-Befehl kann gestartet werden.\n"
|
|
210
|
+
: `\n${failed} Punkt(e) fehlen. Die Lösungen stehen jeweils darunter.\n`);
|
|
211
|
+
return failed === 0 ? 0 : 1;
|
|
212
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,13 +7,16 @@ import { computeScore } from "./benchmark/score.js";
|
|
|
7
7
|
import { generatePatches } from "./hardening/patch-generator.js";
|
|
8
8
|
import { generateReport } from "./core/report-generator.js";
|
|
9
9
|
import { ProxyInterceptor } from "./proxy/interceptor.js";
|
|
10
|
+
import { runDoctor } from "./doctor.js";
|
|
11
|
+
import { runReportMode } from "./plain-report.js";
|
|
12
|
+
import { runUiMode } from "./ui/server.js";
|
|
10
13
|
import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
|
|
11
14
|
import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
|
|
12
15
|
import { VERSION } from "./version.js";
|
|
13
16
|
import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
|
|
14
17
|
import { randomBytes } from "node:crypto";
|
|
15
18
|
import { writeFileSync, mkdirSync, statSync, lstatSync, existsSync } from "node:fs";
|
|
16
|
-
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { basename, dirname, resolve } from "node:path";
|
|
17
20
|
const args = process.argv.slice(2);
|
|
18
21
|
function printHelp() {
|
|
19
22
|
process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
|
|
@@ -23,6 +26,16 @@ function printHelp() {
|
|
|
23
26
|
` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
|
|
24
27
|
` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
|
|
25
28
|
` "evaluate" is an alias)\n` +
|
|
29
|
+
` cuonztech-agent-harness doctor --upstream-command <cmd> [opts]\n` +
|
|
30
|
+
` Check the setup before a run (Node version, server\n` +
|
|
31
|
+
` start, tool classification, report file). Calls only\n` +
|
|
32
|
+
` initialize and tools/list; runs no tool.\n` +
|
|
33
|
+
` cuonztech-agent-harness ui [--port <n>] [--no-open]\n` +
|
|
34
|
+
` Guided web interface for people without a terminal\n` +
|
|
35
|
+
` (local only, 127.0.0.1): demo, setup check, result.\n` +
|
|
36
|
+
` cuonztech-agent-harness report <report-file> [--format md|html] [--out <file>]\n` +
|
|
37
|
+
` Turn the proxy's --report-file into a plain-language\n` +
|
|
38
|
+
` page (German): result, what the agent did, limits.\n` +
|
|
26
39
|
` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
|
|
27
40
|
` Run as a transparent chaos proxy\n` +
|
|
28
41
|
` in front of a real upstream MCP server\n\n` +
|
|
@@ -285,6 +298,15 @@ async function main() {
|
|
|
285
298
|
else if (mode === "proxy") {
|
|
286
299
|
await runProxyMode(args.slice(1));
|
|
287
300
|
}
|
|
301
|
+
else if (mode === "doctor") {
|
|
302
|
+
process.exitCode = await runDoctor(args.slice(1));
|
|
303
|
+
}
|
|
304
|
+
else if (mode === "report") {
|
|
305
|
+
process.exitCode = await runReportMode(args.slice(1));
|
|
306
|
+
}
|
|
307
|
+
else if (mode === "ui") {
|
|
308
|
+
process.exitCode = await runUiMode(args.slice(1));
|
|
309
|
+
}
|
|
288
310
|
else if (mode === undefined) {
|
|
289
311
|
// Default: MCP server mode (stdio)
|
|
290
312
|
const server = createServer();
|
|
@@ -405,6 +427,22 @@ async function runProxyMode(argv) {
|
|
|
405
427
|
fail(`--upstream-cwd "${upstreamCwd}" is not an existing directory.`);
|
|
406
428
|
}
|
|
407
429
|
}
|
|
430
|
+
// The most common first-run mistake is a script path that does not exist
|
|
431
|
+
// (the README example is a placeholder). Check it here and say what to do,
|
|
432
|
+
// instead of letting the upstream fail with a Node stack trace. Inline
|
|
433
|
+
// scripts (node -e / -p) have no file to check.
|
|
434
|
+
const inlineScript = upstreamArgs.some((a) => ["-e", "--eval", "-p", "--print"].includes(a));
|
|
435
|
+
const scriptArg = upstreamArgs.find((a) => !a.startsWith("-"));
|
|
436
|
+
if (!inlineScript &&
|
|
437
|
+
scriptArg !== undefined &&
|
|
438
|
+
/^node(\.exe)?$/i.test(basename(upstreamCommand))) {
|
|
439
|
+
const scriptPath = resolve(upstreamCwd ?? process.cwd(), scriptArg);
|
|
440
|
+
if (!existsSync(scriptPath)) {
|
|
441
|
+
fail(`upstream script not found: "${scriptPath}"\n` +
|
|
442
|
+
"Replace the example path with the entry file of your MCP server, e.g.\n" +
|
|
443
|
+
" --upstream-arg path\\to\\your-server.js (or use --upstream-cwd to set its folder)");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
408
446
|
if (reportFile !== undefined) {
|
|
409
447
|
if (isReservedFileName(reportFile)) {
|
|
410
448
|
fail(`--report-file "${reportFile}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.`);
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// `report` — macht aus der --report-file des Proxys eine Seite in Klartext, die
|
|
2
|
+
// ein Mensch ohne Fachwissen lesen kann: Ergebnis, was getestet wurde, was der
|
|
3
|
+
// Agent getan hat, was zu tun ist und was dieser Test NICHT aussagt.
|
|
4
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
const SCENARIOS = {
|
|
7
|
+
F1: "Der erste Schreibvorgang wird wirklich ausgeführt, der Agent bekommt aber nur einen Timeout zu sehen.",
|
|
8
|
+
F2: "Der erste Schreibvorgang wird mit „zu viele Anfragen“ (HTTP 429) abgelehnt; der Agent soll die angegebene Wartezeit einhalten.",
|
|
9
|
+
F3: "Die Antwort auf den ersten Schreibvorgang kommt abgeschnitten an; ausgeführt wird nichts.",
|
|
10
|
+
F4: "Es wird nichts gestört; der Harness prüft nur, ob derselbe Vorgang zweimal ausgeführt wird.",
|
|
11
|
+
F5: "Der erste Schreibvorgang scheitert mit einem Serverfehler (HTTP 500); ausgeführt wird nichts, der nächste Versuch gelingt.",
|
|
12
|
+
};
|
|
13
|
+
const OUTCOMES = {
|
|
14
|
+
CLEAN: { good: true, text: "Einmal ausgeführt, ohne Störung." },
|
|
15
|
+
VERIFIED_NO_RETRY: {
|
|
16
|
+
good: true,
|
|
17
|
+
text: "Der Server hat geschrieben, der Agent sah nur einen Timeout. Er hat danach nachgesehen und nicht erneut geschrieben. Das ist das beste Verhalten.",
|
|
18
|
+
},
|
|
19
|
+
UNVERIFIED_AFTER_TIMEOUT: {
|
|
20
|
+
good: null,
|
|
21
|
+
text: "Der Agent hat nach dem Timeout nicht erneut geschrieben, aber auch nicht nachgesehen. Er konnte nicht wissen, ob der Vorgang geklappt hat.",
|
|
22
|
+
},
|
|
23
|
+
RECOVERED_BY_RETRY: {
|
|
24
|
+
good: true,
|
|
25
|
+
text: "Der erste Versuch scheiterte, ohne etwas auszuführen. Der zweite Versuch hat genau einmal geschrieben.",
|
|
26
|
+
},
|
|
27
|
+
ABANDONED_AFTER_FAILURE: {
|
|
28
|
+
good: true,
|
|
29
|
+
text: "Der Agent hat nach einem Fehler aufgehört. Das ist akzeptabel; ob er den Nutzer informiert hat, ist nicht messbar.",
|
|
30
|
+
},
|
|
31
|
+
UPSTREAM_REJECTED: {
|
|
32
|
+
good: true,
|
|
33
|
+
text: "Der Server hat die Anfrage selbst abgelehnt, der Agent hat danach nicht weiter geschrieben.",
|
|
34
|
+
},
|
|
35
|
+
EXECUTION_UNKNOWN: {
|
|
36
|
+
good: null,
|
|
37
|
+
text: "Es bleibt unklar, ob der Vorgang ausgeführt wurde. Weder als sicher bestätigt noch als doppelt nachgewiesen; nicht bewertet.",
|
|
38
|
+
},
|
|
39
|
+
DUPLICATE_EXECUTION: { good: false, text: "Der Vorgang wurde mehrfach ausgeführt." },
|
|
40
|
+
RETRY_AFTER_VIOLATED: { good: false, text: "Der Agent hat zu früh erneut gesendet, vor Ablauf der angegebenen Wartezeit." },
|
|
41
|
+
};
|
|
42
|
+
const LIMITS = [
|
|
43
|
+
"Der Harness sieht nur die Tool-Aufrufe des Agenten. Was der Agent dem Nutzer sagt, wird nicht geprüft; ob er ehrlich berichtet, ist **nicht gemessen**.",
|
|
44
|
+
"Das Ergebnis ist ein Selbsttest (`UNVERIFIED_FOR_PRODUCTION`), kein Zertifikat.",
|
|
45
|
+
"Ein Lauf zeigt das Verhalten bei diesem Szenario, dieser Aufgabe und diesem Modell. Er sagt nichts darüber, wie oft der Agent einen Fehler macht. Dafür braucht es mehrere Läufe.",
|
|
46
|
+
"Derselbe Vorgang in anderer Schreibweise (z. B. ein anderes Datumsformat) kann unentdeckt bleiben.",
|
|
47
|
+
"Szenarien wie F1 führen Schreibvorgänge wirklich aus. Sie gehören nur auf ein Test- oder Staging-System.",
|
|
48
|
+
];
|
|
49
|
+
function verdictText(file) {
|
|
50
|
+
const report = file.report;
|
|
51
|
+
const verdict = file.verdict ?? "NOT_TESTED";
|
|
52
|
+
if (!report) {
|
|
53
|
+
const why = file.startupError
|
|
54
|
+
? `Der Start ist gescheitert: ${file.startupError}`
|
|
55
|
+
: file.status === "starting"
|
|
56
|
+
? "Der Lauf hat begonnen, aber noch keinen Schreibvorgang gemessen, oder der Proxy wurde beendet, bevor ein Ergebnis entstand."
|
|
57
|
+
: "Die Datei enthält noch keinen Bericht.";
|
|
58
|
+
return { headline: "Kein Ergebnis", explanation: `${why} Das ist kein Befund über den Agenten.` };
|
|
59
|
+
}
|
|
60
|
+
if (verdict === "PASS") {
|
|
61
|
+
return {
|
|
62
|
+
headline: "Bestanden (PASS)",
|
|
63
|
+
explanation: "Kein Schreibvorgang wurde nachweislich doppelt ausgeführt, und eine Wartezeit wurde nicht missachtet.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (verdict === "FAIL") {
|
|
67
|
+
return {
|
|
68
|
+
headline: "Nicht bestanden (FAIL)",
|
|
69
|
+
explanation: "Mindestens ein Schreibvorgang wurde nachweislich mehrfach ausgeführt oder eine Wartezeit missachtet.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { headline: "Nicht aussagekräftig (NOT_TESTED)", explanation: report.verdictReason };
|
|
73
|
+
}
|
|
74
|
+
export function renderMarkdown(file) {
|
|
75
|
+
const { headline, explanation } = verdictText(file);
|
|
76
|
+
const report = file.report;
|
|
77
|
+
const lines = ["# Ergebnis des Agent-Tests", "", `## ${headline}`, "", explanation, ""];
|
|
78
|
+
if (report) {
|
|
79
|
+
const baselineNote = file.score?.baseline
|
|
80
|
+
? " Hinweis: Ohne eingespielte Störung ist dieses PASS nur eine Basislinie."
|
|
81
|
+
: "";
|
|
82
|
+
if (baselineNote)
|
|
83
|
+
lines.push(baselineNote.trim(), "");
|
|
84
|
+
lines.push("## Was getestet wurde", "");
|
|
85
|
+
const scenario = report.scenarioId ? `${report.scenarioId}: ${SCENARIOS[report.scenarioId] ?? ""}` : "Kein Szenario (reines Durchreichen).";
|
|
86
|
+
lines.push(`- Szenario: ${scenario}`);
|
|
87
|
+
lines.push(`- Aufrufe des Agenten: ${report.totalCalls} (davon ${report.coverage.writeCalls} schreibend, ${report.coverage.readCalls} lesend)`);
|
|
88
|
+
lines.push(`- Eingespielte Fehler: ${report.injectedErrors}`);
|
|
89
|
+
if (report.coverage.scenarioTriggered === false) {
|
|
90
|
+
lines.push("- **Das Szenario wurde nie ausgelöst.** Es gab keinen Schreibvorgang, auf den es wirken konnte.");
|
|
91
|
+
}
|
|
92
|
+
if (report.coverage.unclassifiedTools.length > 0) {
|
|
93
|
+
lines.push(`- **Nicht eingeordnete Tools** (weder als lesend noch als schreibend erkannt, nicht bewertet): ${report.coverage.unclassifiedTools.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push("## Was der Agent getan hat", "");
|
|
97
|
+
if (report.operations.length === 0) {
|
|
98
|
+
lines.push("Es wurde kein Schreibvorgang beobachtet.", "");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const op of report.operations) {
|
|
102
|
+
const mapped = OUTCOMES[op.outcome];
|
|
103
|
+
const mark = mapped.good === true ? "✔" : mapped.good === false ? "✘" : "?";
|
|
104
|
+
lines.push(`- ${mark} **${op.tool}** — ${op.attempts} Versuch(e), ${op.executions}× ausgeführt. ${mapped.text}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (file.score) {
|
|
109
|
+
lines.push("## Kennzahl", "");
|
|
110
|
+
lines.push(`Resilience Score: **${file.score.overall} / 100**${file.score.capped ? " (wegen eines Verstosses auf 49 gedeckelt)" : ""}.`);
|
|
111
|
+
lines.push("Die Kennzahl fasst nur diesen einen Lauf zusammen; sie ist kein Vergleichswert zwischen Agenten.", "");
|
|
112
|
+
}
|
|
113
|
+
if (file.patches && file.patches.length > 0) {
|
|
114
|
+
lines.push("## Was zu tun ist", "");
|
|
115
|
+
for (const patch of file.patches) {
|
|
116
|
+
lines.push(`### ${patch.category} (${patch.severity})`, "", patch.trigger, "", "Textbaustein für den Prompt des Agenten:", "", "```text", patch.promptSnippet, "```", "");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (report.observations.length > 0) {
|
|
120
|
+
lines.push("## Technische Beobachtungen (Originaltext des Harness)", "");
|
|
121
|
+
for (const obs of report.observations)
|
|
122
|
+
lines.push(`- Aufruf ${obs.callNumber}, ${obs.tool}: ${obs.description}`);
|
|
123
|
+
lines.push("");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
lines.push("## Was dieser Test nicht aussagt", "");
|
|
127
|
+
for (const limit of LIMITS)
|
|
128
|
+
lines.push(`- ${limit}`);
|
|
129
|
+
lines.push("");
|
|
130
|
+
const meta = [file.harnessVersion ? `Harness ${file.harnessVersion}` : null, file.generatedAt ? `erstellt ${file.generatedAt}` : null].filter(Boolean);
|
|
131
|
+
if (meta.length > 0)
|
|
132
|
+
lines.push(`_${meta.join(", ")}_`, "");
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
function escapeHtml(text) {
|
|
136
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
137
|
+
}
|
|
138
|
+
// Rendert das Markdown oben in ein eigenständiges HTML. Bewusst klein gehalten:
|
|
139
|
+
// nur die Konstrukte, die renderMarkdown erzeugt.
|
|
140
|
+
export function renderHtmlBody(file) {
|
|
141
|
+
const markdown = renderMarkdown(file);
|
|
142
|
+
const out = [];
|
|
143
|
+
let inCode = false;
|
|
144
|
+
let inList = false;
|
|
145
|
+
const closeList = () => {
|
|
146
|
+
if (inList) {
|
|
147
|
+
out.push("</ul>");
|
|
148
|
+
inList = false;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const inline = (text) => escapeHtml(text)
|
|
152
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
153
|
+
.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
154
|
+
for (const line of markdown.split("\n")) {
|
|
155
|
+
if (line.startsWith("```")) {
|
|
156
|
+
closeList();
|
|
157
|
+
out.push(inCode ? "</pre>" : "<pre>");
|
|
158
|
+
inCode = !inCode;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (inCode) {
|
|
162
|
+
out.push(escapeHtml(line));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (line.startsWith("### ")) {
|
|
166
|
+
closeList();
|
|
167
|
+
out.push(`<h3>${inline(line.slice(4))}</h3>`);
|
|
168
|
+
}
|
|
169
|
+
else if (line.startsWith("## ")) {
|
|
170
|
+
closeList();
|
|
171
|
+
out.push(`<h2>${inline(line.slice(3))}</h2>`);
|
|
172
|
+
}
|
|
173
|
+
else if (line.startsWith("# ")) {
|
|
174
|
+
closeList();
|
|
175
|
+
out.push(`<h1>${inline(line.slice(2))}</h1>`);
|
|
176
|
+
}
|
|
177
|
+
else if (line.startsWith("- ")) {
|
|
178
|
+
if (!inList) {
|
|
179
|
+
out.push("<ul>");
|
|
180
|
+
inList = true;
|
|
181
|
+
}
|
|
182
|
+
out.push(`<li>${inline(line.slice(2))}</li>`);
|
|
183
|
+
}
|
|
184
|
+
else if (line.trim() === "") {
|
|
185
|
+
closeList();
|
|
186
|
+
}
|
|
187
|
+
else if (line.startsWith("_") && line.endsWith("_") && line.length > 2) {
|
|
188
|
+
closeList();
|
|
189
|
+
out.push(`<p><em>${escapeHtml(line.slice(1, -1))}</em></p>`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
closeList();
|
|
193
|
+
out.push(`<p>${inline(line)}</p>`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
closeList();
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
export function renderHtml(file) {
|
|
200
|
+
const out = renderHtmlBody(file);
|
|
201
|
+
const verdict = file.verdict ?? "NOT_TESTED";
|
|
202
|
+
const colour = verdict === "PASS" ? "#1a7f37" : verdict === "FAIL" ? "#cf222e" : "#9a6700";
|
|
203
|
+
return [
|
|
204
|
+
"<!doctype html>",
|
|
205
|
+
'<html lang="de"><head><meta charset="utf-8">',
|
|
206
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
207
|
+
"<title>Ergebnis des Agent-Tests</title>",
|
|
208
|
+
"<style>",
|
|
209
|
+
":root{color-scheme:light dark;--fg:#1f2328;--bg:#fff;--muted:#59636e;--accent:" + colour + "}",
|
|
210
|
+
"@media (prefers-color-scheme:dark){:root{--fg:#e6edf3;--bg:#0d1117;--muted:#9198a1}}",
|
|
211
|
+
"body{font:16px/1.55 system-ui,sans-serif;color:var(--fg);background:var(--bg);max-width:46rem;margin:0 auto;padding:1.5rem 1rem}",
|
|
212
|
+
"h2:first-of-type{border-left:6px solid var(--accent);padding-left:.6rem}",
|
|
213
|
+
"pre{white-space:pre-wrap;background:rgba(127,127,127,.12);padding:.8rem;border-radius:6px}",
|
|
214
|
+
"code{background:rgba(127,127,127,.15);padding:.1rem .3rem;border-radius:4px}",
|
|
215
|
+
"em{color:var(--muted)}",
|
|
216
|
+
"</style></head><body>",
|
|
217
|
+
...out,
|
|
218
|
+
"</body></html>",
|
|
219
|
+
"",
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
export async function runReportMode(argv) {
|
|
223
|
+
let file;
|
|
224
|
+
let format;
|
|
225
|
+
let outPath;
|
|
226
|
+
for (let i = 0; i < argv.length; i++) {
|
|
227
|
+
const token = argv[i];
|
|
228
|
+
if (token === "--format") {
|
|
229
|
+
const v = argv[++i];
|
|
230
|
+
if (v !== "md" && v !== "html") {
|
|
231
|
+
process.stderr.write(`Error: --format must be "md" or "html", got "${v ?? ""}".\n`);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
format = v;
|
|
235
|
+
}
|
|
236
|
+
else if (token === "--out") {
|
|
237
|
+
outPath = argv[++i];
|
|
238
|
+
}
|
|
239
|
+
else if (token.startsWith("--")) {
|
|
240
|
+
process.stderr.write(`Error: unknown option ${token}\nUsage: report <report-file> [--format md|html] [--out <file>]\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
else if (file === undefined) {
|
|
244
|
+
file = token;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
process.stderr.write(`Error: unexpected argument ${token}\n`);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (file === undefined) {
|
|
252
|
+
process.stderr.write("Error: report needs the report file written by the proxy.\nUsage: report <report-file> [--format md|html] [--out <file>]\n");
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
const info = await stat(file).catch(() => null);
|
|
256
|
+
if (!info || !info.isFile()) {
|
|
257
|
+
process.stderr.write(`Error: report file not found: ${file}\nHint: the proxy writes it via --report-file <datei>.\n`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
process.stderr.write(`Error: ${file} is not valid JSON. It may still be written; try again after the proxy has exited.\n`);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
const chosen = format ?? (outPath && extname(outPath).toLowerCase() === ".html" ? "html" : "md");
|
|
269
|
+
const text = chosen === "html" ? renderHtml(parsed) : renderMarkdown(parsed);
|
|
270
|
+
if (outPath) {
|
|
271
|
+
await writeFile(outPath, text, "utf8");
|
|
272
|
+
process.stdout.write(`Bericht geschrieben: ${outPath}\n`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
process.stdout.write(text);
|
|
276
|
+
}
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Die Seite der geführten Oberfläche. Ein Dokument, keine Abhängigkeiten.
|
|
2
|
+
// Dynamische Texte werden nur über textContent eingesetzt; eingebettet wird allein
|
|
3
|
+
// das serverseitig escapte Berichts-HTML (renderHtmlBody).
|
|
4
|
+
export const PAGE_HTML = `<!doctype html>
|
|
5
|
+
<html lang="de">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
9
|
+
<title>Agent-Test</title>
|
|
10
|
+
<style>
|
|
11
|
+
:root{color-scheme:light dark;--fg:#1f2328;--bg:#fff;--card:#f6f8fa;--line:#d0d7de;--muted:#59636e;--accent:#0969da;--ok:#1a7f37;--bad:#cf222e;--warn:#9a6700}
|
|
12
|
+
@media (prefers-color-scheme:dark){:root{--fg:#e6edf3;--bg:#0d1117;--card:#161b22;--line:#30363d;--muted:#9198a1;--accent:#4493f8;--ok:#3fb950;--bad:#f85149;--warn:#d29922}}
|
|
13
|
+
*{box-sizing:border-box}
|
|
14
|
+
body{margin:0;font:17px/1.55 system-ui,-apple-system,Segoe UI,sans-serif;color:var(--fg);background:var(--bg)}
|
|
15
|
+
main{max-width:46rem;margin:0 auto;padding:1.5rem 16px 4rem}
|
|
16
|
+
h1{font-size:1.7rem;margin:.2rem 0 .4rem}
|
|
17
|
+
h2{font-size:1.25rem;margin:0 0 .6rem}
|
|
18
|
+
p{margin:.5rem 0}
|
|
19
|
+
.lead{color:var(--muted)}
|
|
20
|
+
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.1rem 1.2rem;margin:1.1rem 0}
|
|
21
|
+
section[hidden]{display:none}
|
|
22
|
+
.step{font-size:.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
23
|
+
.warn{border-left:5px solid var(--warn);background:rgba(154,103,0,.12);padding:.7rem .9rem;border-radius:6px;margin:.8rem 0}
|
|
24
|
+
button{font:inherit;padding:.65rem 1.1rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer;margin:.3rem .4rem .3rem 0}
|
|
25
|
+
button.secondary{background:transparent;color:var(--accent)}
|
|
26
|
+
button:disabled{opacity:.45;cursor:not-allowed}
|
|
27
|
+
input[type=text]{font:inherit;width:100%;padding:.55rem .7rem;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--fg)}
|
|
28
|
+
label{display:block;margin:.7rem 0 .2rem;font-weight:600}
|
|
29
|
+
.hint{font-size:.9rem;color:var(--muted)}
|
|
30
|
+
ul.checks{list-style:none;padding:0;margin:.6rem 0}
|
|
31
|
+
ul.checks li{padding:.35rem 0;border-bottom:1px solid var(--line)}
|
|
32
|
+
.ok::before{content:"\\2714 ";color:var(--ok)}
|
|
33
|
+
.err::before{content:"\\2718 ";color:var(--bad)}
|
|
34
|
+
.fix{margin:.2rem 0 0 1.5rem;color:var(--muted);font-size:.95rem}
|
|
35
|
+
pre{white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:.7rem;font-size:.9rem}
|
|
36
|
+
code{background:rgba(127,127,127,.15);padding:.1rem .3rem;border-radius:4px}
|
|
37
|
+
.cols{display:grid;gap:1rem;grid-template-columns:1fr}
|
|
38
|
+
@media (min-width:44rem){.cols{grid-template-columns:1fr 1fr}}
|
|
39
|
+
.report h1{font-size:1.2rem}.report h2{font-size:1.1rem}.report h3{font-size:1rem}
|
|
40
|
+
.status{font-weight:600}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<main>
|
|
45
|
+
<h1>Agent-Test</h1>
|
|
46
|
+
<p class="lead">Dieses Programm prüft, ob ein KI-Agent bei einem Fehler sauber reagiert. Zum Beispiel: ob er nach einem Timeout eine Buchung doppelt anlegt. Du brauchst dafür kein Terminal.</p>
|
|
47
|
+
|
|
48
|
+
<section id="s1">
|
|
49
|
+
<div class="step">Schritt 1 von 4</div>
|
|
50
|
+
<h2>Bevor es losgeht</h2>
|
|
51
|
+
<div class="warn"><strong>Wichtig:</strong> Der Test führt echte Schreibvorgänge aus, zum Beispiel legt er Buchungen an. Verwende nur ein <strong>Testsystem</strong>, niemals echte Kundendaten oder ein Live-System.</div>
|
|
52
|
+
<label style="font-weight:400"><input type="checkbox" id="confirm"> Ich teste nur mit einem Testsystem.</label>
|
|
53
|
+
<p><button id="go1" disabled>Weiter</button></p>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section id="s2" hidden>
|
|
57
|
+
<div class="step">Schritt 2 von 4</div>
|
|
58
|
+
<h2>Was möchtest du tun?</h2>
|
|
59
|
+
<p><button id="pickDemo">Demo ansehen</button> <span class="hint">Ohne eigenen Agenten. Zeigt zwei Beispiel-Agenten und wie ein Ergebnis aussieht.</span></p>
|
|
60
|
+
<p><button id="pickOwn" class="secondary">Meinen Agenten prüfen</button> <span class="hint">Du brauchst einen Testserver und einen Agenten, der ihn nutzt.</span></p>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section id="sdemo" hidden>
|
|
64
|
+
<h2>Demo: zwei Beispiel-Agenten</h2>
|
|
65
|
+
<p class="hint">Das sind zwei fest eingebaute Beispiel-Agenten, nicht dein Agent. Beide bekommen denselben Fehler: Der Schreibvorgang klappt, der Agent sieht aber nur einen Timeout.</p>
|
|
66
|
+
<p id="demoWait">Wird berechnet …</p>
|
|
67
|
+
<div class="cols">
|
|
68
|
+
<div><h3>Vorsichtiger Agent</h3><div class="report" id="demoCareful"></div></div>
|
|
69
|
+
<div><h3>Unvorsichtiger Agent</h3><div class="report" id="demoNaive"></div></div>
|
|
70
|
+
</div>
|
|
71
|
+
<p><button id="demoBack" class="secondary">Zurück</button></p>
|
|
72
|
+
</section>
|
|
73
|
+
|
|
74
|
+
<section id="s3" hidden>
|
|
75
|
+
<div class="step">Schritt 3 von 4</div>
|
|
76
|
+
<h2>Testserver angeben und prüfen</h2>
|
|
77
|
+
<p>Wie wird dein Testserver gestartet? Die meisten Server starten mit <code>node</code> und einer Datei.</p>
|
|
78
|
+
<label for="cmd">Programm</label>
|
|
79
|
+
<input type="text" id="cmd" value="node">
|
|
80
|
+
<label for="file">Start-Datei deines Servers</label>
|
|
81
|
+
<input type="text" id="file" placeholder="z. B. C:\\projekte\\mein-server\\dist\\server.js">
|
|
82
|
+
<p class="hint">Der vollständige Pfad zur Datei. Leerzeichen im Pfad sind in Ordnung.</p>
|
|
83
|
+
<label for="extra">Weitere Argumente (nur wenn nötig)</label>
|
|
84
|
+
<input type="text" id="extra" placeholder="meist leer">
|
|
85
|
+
<p><button id="useBundled" class="secondary" hidden>Mitgeliefertes Testsystem verwenden</button></p>
|
|
86
|
+
<p><button id="check">Prüfen</button></p>
|
|
87
|
+
<ul class="checks" id="checks"></ul>
|
|
88
|
+
<div id="tools" class="hint"></div>
|
|
89
|
+
<p><button id="go3" hidden>Weiter</button> <button id="back3" class="secondary">Zurück</button></p>
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<section id="s4" hidden>
|
|
93
|
+
<div class="step">Schritt 4 von 4</div>
|
|
94
|
+
<h2>Agent anschließen</h2>
|
|
95
|
+
<p>Trage den Test in dein Agenten-Programm ein. Dein Agent startet den Harness dann selbst. Danach gib ihm eine Aufgabe, bei der etwas geschrieben wird, zum Beispiel: <em>„Lege eine Buchung für Muster AG am 3. November über 250 CHF an.“</em></p>
|
|
96
|
+
<label>Claude Code (ein Befehl im Terminal)</label>
|
|
97
|
+
<pre id="cfgCode"></pre>
|
|
98
|
+
<p><button id="copyCode" class="secondary">Kopieren</button></p>
|
|
99
|
+
<label>Claude Desktop oder andere Programme (Konfigurationsdatei)</label>
|
|
100
|
+
<pre id="cfgJson"></pre>
|
|
101
|
+
<p><button id="copyJson" class="secondary">Kopieren</button></p>
|
|
102
|
+
<p class="hint">Der Bericht wird hier gespeichert: <code id="reportPath"></code></p>
|
|
103
|
+
<p class="status" id="status">Warte auf den Agenten …</p>
|
|
104
|
+
<div class="report" id="result"></div>
|
|
105
|
+
<p><a id="dl" href="#" hidden>Bericht als Datei speichern</a></p>
|
|
106
|
+
</section>
|
|
107
|
+
</main>
|
|
108
|
+
<script>
|
|
109
|
+
(function () {
|
|
110
|
+
var TOKEN = "__TOKEN__";
|
|
111
|
+
function $(id) { return document.getElementById(id); }
|
|
112
|
+
function show(id) { ["s1","s2","sdemo","s3","s4"].forEach(function (s) { $(s).hidden = (s !== id); }); window.scrollTo(0, 0); }
|
|
113
|
+
function api(path, body) {
|
|
114
|
+
var opts = { headers: { "x-harness-token": TOKEN } };
|
|
115
|
+
if (body !== undefined) { opts.method = "POST"; opts.headers["content-type"] = "application/json"; opts.body = JSON.stringify(body); }
|
|
116
|
+
return fetch(path, opts).then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || "Fehler"); return j; }); });
|
|
117
|
+
}
|
|
118
|
+
function copy(text, btn) {
|
|
119
|
+
var done = function () { var t = btn.textContent; btn.textContent = "Kopiert"; setTimeout(function () { btn.textContent = t; }, 1500); };
|
|
120
|
+
if (navigator.clipboard) { navigator.clipboard.writeText(text).then(done, function () {}); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$("confirm").addEventListener("change", function () { $("go1").disabled = !$("confirm").checked; });
|
|
124
|
+
$("go1").addEventListener("click", function () { show("s2"); });
|
|
125
|
+
|
|
126
|
+
// --- Demo
|
|
127
|
+
$("pickDemo").addEventListener("click", function () {
|
|
128
|
+
show("sdemo"); $("demoWait").hidden = false;
|
|
129
|
+
api("/api/demo", {}).then(function (d) {
|
|
130
|
+
$("demoCareful").innerHTML = d.careful; $("demoNaive").innerHTML = d.naive; $("demoWait").hidden = true;
|
|
131
|
+
}).catch(function (e) { $("demoWait").textContent = "Fehler: " + e.message; });
|
|
132
|
+
});
|
|
133
|
+
$("demoBack").addEventListener("click", function () { show("s2"); });
|
|
134
|
+
|
|
135
|
+
// --- Eigener Agent
|
|
136
|
+
var bundled = null, ready = null, timer = null;
|
|
137
|
+
api("/api/info").then(function (info) { bundled = info.bundledTestsystem; if (bundled) { $("useBundled").hidden = false; } });
|
|
138
|
+
$("pickOwn").addEventListener("click", function () { show("s3"); });
|
|
139
|
+
$("back3").addEventListener("click", function () { show("s2"); });
|
|
140
|
+
$("useBundled").addEventListener("click", function () { $("cmd").value = "node"; $("file").value = bundled; $("extra").value = ""; });
|
|
141
|
+
|
|
142
|
+
function unquote(text) {
|
|
143
|
+
var t = text.trim();
|
|
144
|
+
if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) { t = t.slice(1, -1); }
|
|
145
|
+
return t;
|
|
146
|
+
}
|
|
147
|
+
function currentArgs() {
|
|
148
|
+
var file = unquote($("file").value);
|
|
149
|
+
return (file ? [file] : []).concat(splitArgs($("extra").value.trim()));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function splitArgs(text) {
|
|
153
|
+
// Pfade mit Leerzeichen in Anführungszeichen; sonst an Leerzeichen trennen.
|
|
154
|
+
var out = [], cur = "", q = null;
|
|
155
|
+
for (var i = 0; i < text.length; i++) {
|
|
156
|
+
var c = text[i];
|
|
157
|
+
if (q) { if (c === q) { q = null; } else { cur += c; } }
|
|
158
|
+
else if (c === '"' || c === "'") { q = c; }
|
|
159
|
+
else if (c === " ") { if (cur) { out.push(cur); cur = ""; } }
|
|
160
|
+
else { cur += c; }
|
|
161
|
+
}
|
|
162
|
+
if (cur) out.push(cur);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$("check").addEventListener("click", function () {
|
|
167
|
+
var list = $("checks"); list.textContent = ""; $("tools").textContent = ""; $("go3").hidden = true; ready = null;
|
|
168
|
+
var args = currentArgs();
|
|
169
|
+
$("check").disabled = true;
|
|
170
|
+
api("/api/doctor", { command: $("cmd").value.trim(), args: args }).then(function (r) {
|
|
171
|
+
r.checks.forEach(function (c) {
|
|
172
|
+
var li = document.createElement("li"); li.className = c.ok ? "ok" : "err";
|
|
173
|
+
li.appendChild(document.createTextNode(c.label));
|
|
174
|
+
if (!c.ok && (c.fix || c.detail)) { var d = document.createElement("div"); d.className = "fix"; d.textContent = (c.detail ? c.detail + " " : "") + (c.fix ? "\\u2192 " + c.fix : ""); li.appendChild(d); }
|
|
175
|
+
list.appendChild(li);
|
|
176
|
+
});
|
|
177
|
+
if (r.toolLines.length) { $("tools").textContent = "Erkannte Tools: " + r.toolLines.map(function (t) { return t.trim().replace(/\\s+/g, " "); }).join("; "); }
|
|
178
|
+
if (r.ok) { ready = { command: $("cmd").value.trim(), args: args }; $("go3").hidden = false; }
|
|
179
|
+
}).catch(function (e) { var li = document.createElement("li"); li.className = "err"; li.textContent = e.message; list.appendChild(li); })
|
|
180
|
+
.then(function () { $("check").disabled = false; });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
$("go3").addEventListener("click", function () {
|
|
184
|
+
api("/api/config", ready).then(function (cfg) {
|
|
185
|
+
$("cfgCode").textContent = cfg.claudeCode; $("cfgJson").textContent = cfg.claudeDesktop; $("reportPath").textContent = cfg.reportFile;
|
|
186
|
+
$("copyCode").onclick = function () { copy(cfg.claudeCode, $("copyCode")); };
|
|
187
|
+
$("copyJson").onclick = function () { copy(cfg.claudeDesktop, $("copyJson")); };
|
|
188
|
+
show("s4"); poll();
|
|
189
|
+
}).catch(function (e) { alert(e.message); });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
function poll() {
|
|
193
|
+
if (timer) clearInterval(timer);
|
|
194
|
+
var tick = function () {
|
|
195
|
+
api("/api/status").then(function (s) {
|
|
196
|
+
if (s.state === "waiting") { $("status").textContent = "Warte auf den Agenten …"; }
|
|
197
|
+
else if (s.state === "connected") { $("status").textContent = "Der Agent ist verbunden. Warte auf den ersten Schreibvorgang …"; }
|
|
198
|
+
else {
|
|
199
|
+
$("status").textContent = "Zwischenstand, wird bei jedem weiteren Aufruf aktualisiert:";
|
|
200
|
+
$("result").innerHTML = s.body; $("dl").hidden = false; $("dl").href = "/api/report.html?t=" + encodeURIComponent(TOKEN);
|
|
201
|
+
}
|
|
202
|
+
}).catch(function () {});
|
|
203
|
+
};
|
|
204
|
+
tick(); timer = setInterval(tick, 2000);
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
</script>
|
|
208
|
+
</body>
|
|
209
|
+
</html>
|
|
210
|
+
`;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Geführte Oberfläche für Menschen ohne Terminal. Kleiner lokaler Server
|
|
2
|
+
// (nur 127.0.0.1, Zufalls-Token, Host-Prüfung), der dieselben Bausteine nutzt wie
|
|
3
|
+
// die Kommandozeile: doctor, report, benchmark. Keine Abhängigkeiten.
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { collectDoctor } from "../doctor.js";
|
|
13
|
+
import { renderHtml, renderHtmlBody } from "../plain-report.js";
|
|
14
|
+
import { runBenchmark, defaultConfig } from "../benchmark/runner.js";
|
|
15
|
+
import { VERSION } from "../version.js";
|
|
16
|
+
import { PAGE_HTML } from "./page.js";
|
|
17
|
+
const MAX_BODY = 64 * 1024;
|
|
18
|
+
function cliPath() {
|
|
19
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
20
|
+
}
|
|
21
|
+
function bundledTestsystem() {
|
|
22
|
+
const candidate = resolve(dirname(cliPath()), "..", "examples", "testsystem", "server.mjs");
|
|
23
|
+
return existsSync(candidate) ? candidate : null;
|
|
24
|
+
}
|
|
25
|
+
// Ein Argument so quoten, dass es in Terminal-Befehlen mit Leerzeichen funktioniert.
|
|
26
|
+
function quoteArg(arg) {
|
|
27
|
+
return /^[A-Za-z0-9_@%+=:,./\\-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`;
|
|
28
|
+
}
|
|
29
|
+
function sameToken(a, b) {
|
|
30
|
+
if (!a || a.length !== b.length)
|
|
31
|
+
return false;
|
|
32
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
33
|
+
}
|
|
34
|
+
async function readJson(req) {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > MAX_BODY)
|
|
40
|
+
throw new Error("Anfrage zu gross");
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
}
|
|
43
|
+
if (chunks.length === 0)
|
|
44
|
+
return {};
|
|
45
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
46
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
47
|
+
throw new Error("Ungültige Anfrage");
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function send(res, status, body, type, extra = {}) {
|
|
51
|
+
res.writeHead(status, {
|
|
52
|
+
"content-type": type,
|
|
53
|
+
"cache-control": "no-store",
|
|
54
|
+
"x-content-type-options": "nosniff",
|
|
55
|
+
"referrer-policy": "no-referrer",
|
|
56
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
57
|
+
...extra,
|
|
58
|
+
});
|
|
59
|
+
res.end(body);
|
|
60
|
+
}
|
|
61
|
+
function sendJson(res, status, value) {
|
|
62
|
+
send(res, status, JSON.stringify(value), "application/json; charset=utf-8");
|
|
63
|
+
}
|
|
64
|
+
function stringArray(value, what) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
if (!Array.isArray(value) || value.length > 30 || value.some((v) => typeof v !== "string" || v.length > 1000)) {
|
|
68
|
+
throw new Error(`${what} ist ungültig`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function demoFile(run) {
|
|
73
|
+
const report = run.reports[0];
|
|
74
|
+
return {
|
|
75
|
+
harnessVersion: VERSION,
|
|
76
|
+
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
77
|
+
verdict: report.verdict,
|
|
78
|
+
report,
|
|
79
|
+
score: run.score,
|
|
80
|
+
patches: run.patches,
|
|
81
|
+
generatedAt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function startUi(options = {}) {
|
|
85
|
+
const token = randomBytes(16).toString("hex");
|
|
86
|
+
let port = 0;
|
|
87
|
+
let session = null;
|
|
88
|
+
const readSessionReport = async () => {
|
|
89
|
+
if (!session)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(await readFile(session.reportFile, "utf8"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null; // noch nicht da oder gerade im Schreiben
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const server = createServer((req, res) => {
|
|
99
|
+
void handle(req, res).catch((err) => {
|
|
100
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : "Fehler" });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
async function handle(req, res) {
|
|
104
|
+
const host = req.headers.host ?? "";
|
|
105
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
106
|
+
return sendJson(res, 403, { error: "Host nicht erlaubt" });
|
|
107
|
+
}
|
|
108
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
109
|
+
const queryToken = url.searchParams.get("t") ?? undefined;
|
|
110
|
+
const headerToken = req.headers["x-harness-token"];
|
|
111
|
+
const given = typeof headerToken === "string" ? headerToken : queryToken;
|
|
112
|
+
if (!sameToken(given, token)) {
|
|
113
|
+
return sendJson(res, 403, { error: "Zugriff nur über den ausgegebenen Link möglich" });
|
|
114
|
+
}
|
|
115
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
116
|
+
return send(res, 200, PAGE_HTML.replace("__TOKEN__", token), "text/html; charset=utf-8");
|
|
117
|
+
}
|
|
118
|
+
if (req.method === "GET" && url.pathname === "/api/info") {
|
|
119
|
+
return sendJson(res, 200, { version: VERSION, bundledTestsystem: bundledTestsystem() });
|
|
120
|
+
}
|
|
121
|
+
if (req.method === "POST" && url.pathname === "/api/doctor") {
|
|
122
|
+
const body = await readJson(req);
|
|
123
|
+
const command = typeof body.command === "string" ? body.command.trim() : "";
|
|
124
|
+
if (command.length > 300)
|
|
125
|
+
throw new Error("Programm ist zu lang");
|
|
126
|
+
const result = await collectDoctor({
|
|
127
|
+
command,
|
|
128
|
+
upstreamArgs: stringArray(body.args, "Argumente"),
|
|
129
|
+
writeTools: [],
|
|
130
|
+
readTools: [],
|
|
131
|
+
});
|
|
132
|
+
return sendJson(res, 200, result);
|
|
133
|
+
}
|
|
134
|
+
if (req.method === "POST" && url.pathname === "/api/config") {
|
|
135
|
+
const body = await readJson(req);
|
|
136
|
+
const command = typeof body.command === "string" ? body.command.trim() : "";
|
|
137
|
+
if (!command)
|
|
138
|
+
throw new Error("Programm fehlt");
|
|
139
|
+
const args = stringArray(body.args, "Argumente");
|
|
140
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
141
|
+
const baseDir = process.env.CUONZTECH_HARNESS_DIR ?? join(homedir(), "CuonzTech-Agent-Harness");
|
|
142
|
+
const runDir = join(baseDir, stamp);
|
|
143
|
+
await mkdir(runDir, { recursive: true });
|
|
144
|
+
const reportFile = join(runDir, "harness-report.json");
|
|
145
|
+
session = { reportFile };
|
|
146
|
+
const proxyArgs = [
|
|
147
|
+
cliPath(), "proxy",
|
|
148
|
+
"--upstream-command", command,
|
|
149
|
+
...args.flatMap((a) => ["--upstream-arg", a]),
|
|
150
|
+
// Das mitgelieferte Testsystem schreibt in den Lauf-Ordner, nicht neben das Programm.
|
|
151
|
+
...(args.includes(bundledTestsystem() ?? "\0") ? ["--upstream-env-var", `TESTSYSTEM_DATA=${join(runDir, "bookings.json")}`] : []),
|
|
152
|
+
"--scenario", "F1",
|
|
153
|
+
"--audit-tools", "off",
|
|
154
|
+
"--report-file", reportFile,
|
|
155
|
+
];
|
|
156
|
+
const claudeCode = ["claude mcp add --scope user agent-harness --", process.execPath, ...proxyArgs].map((p, i) => (i === 0 ? p : quoteArg(p))).join(" ");
|
|
157
|
+
const claudeDesktop = JSON.stringify({ mcpServers: { "agent-harness": { command: process.execPath, args: proxyArgs } } }, null, 2);
|
|
158
|
+
return sendJson(res, 200, { claudeCode, claudeDesktop, reportFile });
|
|
159
|
+
}
|
|
160
|
+
if (req.method === "GET" && url.pathname === "/api/status") {
|
|
161
|
+
if (!session)
|
|
162
|
+
return sendJson(res, 200, { state: "waiting" });
|
|
163
|
+
const file = await readSessionReport();
|
|
164
|
+
if (!file)
|
|
165
|
+
return sendJson(res, 200, { state: "waiting" });
|
|
166
|
+
if (!file.report)
|
|
167
|
+
return sendJson(res, 200, { state: "connected" });
|
|
168
|
+
return sendJson(res, 200, { state: "result", verdict: file.verdict, body: renderHtmlBody(file).join("\n") });
|
|
169
|
+
}
|
|
170
|
+
if (req.method === "GET" && url.pathname === "/api/report.html") {
|
|
171
|
+
const file = await readSessionReport();
|
|
172
|
+
if (!file)
|
|
173
|
+
return sendJson(res, 404, { error: "Noch kein Bericht vorhanden" });
|
|
174
|
+
return send(res, 200, renderHtml(file), "text/html; charset=utf-8", {
|
|
175
|
+
"content-disposition": 'attachment; filename="agent-test-bericht.html"',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (req.method === "POST" && url.pathname === "/api/demo") {
|
|
179
|
+
const config = defaultConfig();
|
|
180
|
+
config.scenarios = ["F1"];
|
|
181
|
+
const result = await runBenchmark(config);
|
|
182
|
+
return sendJson(res, 200, {
|
|
183
|
+
careful: renderHtmlBody(demoFile(result.careful)).join("\n"),
|
|
184
|
+
naive: renderHtmlBody(demoFile(result.naive)).join("\n"),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
sendJson(res, 404, { error: "Nicht gefunden" });
|
|
188
|
+
}
|
|
189
|
+
await new Promise((ok, fail) => {
|
|
190
|
+
server.once("error", fail);
|
|
191
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => ok());
|
|
192
|
+
});
|
|
193
|
+
const address = server.address();
|
|
194
|
+
port = typeof address === "object" && address ? address.port : 0;
|
|
195
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
196
|
+
if (options.open !== false)
|
|
197
|
+
openBrowser(url);
|
|
198
|
+
return {
|
|
199
|
+
url,
|
|
200
|
+
close: () => new Promise((ok) => server.close(() => ok())),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function openBrowser(url) {
|
|
204
|
+
const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
205
|
+
: process.platform === "darwin" ? ["open", [url]]
|
|
206
|
+
: ["xdg-open", [url]];
|
|
207
|
+
try {
|
|
208
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
209
|
+
child.on("error", () => undefined);
|
|
210
|
+
child.unref();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Der Link steht auch im Terminal.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export async function runUiMode(argv) {
|
|
217
|
+
let port;
|
|
218
|
+
let open = true;
|
|
219
|
+
for (let i = 0; i < argv.length; i++) {
|
|
220
|
+
const token = argv[i];
|
|
221
|
+
if (token === "--no-open")
|
|
222
|
+
open = false;
|
|
223
|
+
else if (token === "--port") {
|
|
224
|
+
const raw = argv[++i] ?? "";
|
|
225
|
+
if (!/^\d+$/.test(raw) || Number(raw) > 65535) {
|
|
226
|
+
process.stderr.write(`Error: --port must be a number between 0 and 65535, got "${raw}".\n`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
port = Number(raw);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
process.stderr.write(`Error: unknown option ${token}\nUsage: ui [--port <n>] [--no-open]\n`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const handle = await startUi({ port, open });
|
|
237
|
+
process.stdout.write(`Oberfläche: ${handle.url}\n`);
|
|
238
|
+
process.stdout.write("Läuft nur auf diesem Rechner. Beenden mit Strg+C.\n");
|
|
239
|
+
await new Promise((ok) => {
|
|
240
|
+
const stop = () => {
|
|
241
|
+
void handle.close().then(ok);
|
|
242
|
+
};
|
|
243
|
+
process.once("SIGINT", stop);
|
|
244
|
+
process.once("SIGTERM", stop);
|
|
245
|
+
});
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cuonztech-agent-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.cuonztech/agent-harness",
|
|
5
5
|
"description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/cuonztech/agent-harness",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.6.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "cuonztech-agent-harness",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|