cuonztech-agent-harness 0.4.0 → 0.5.1

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.
Files changed (78) hide show
  1. package/README.md +260 -151
  2. package/dist/benchmark/cta.js +45 -31
  3. package/dist/benchmark/runner.js +105 -87
  4. package/dist/benchmark/score.js +115 -60
  5. package/dist/core/report-generator.js +704 -258
  6. package/dist/core/state-engine.js +1 -2
  7. package/dist/engine/state-diff.js +4 -2
  8. package/dist/engine/state-machine.js +164 -35
  9. package/dist/hardening/patch-generator.js +58 -48
  10. package/dist/index.js +737 -148
  11. package/dist/proxy/gateway.js +71 -65
  12. package/dist/proxy/ghost-write.js +37 -10
  13. package/dist/proxy/interceptor.js +367 -84
  14. package/dist/scenarios/f1-f5.js +34 -27
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +70 -18
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +80 -43
  19. package/dist/tools/schemas.js +27 -11
  20. package/dist/version.js +0 -1
  21. package/package.json +4 -3
  22. package/dist/benchmark/cta.d.ts +0 -13
  23. package/dist/benchmark/cta.d.ts.map +0 -1
  24. package/dist/benchmark/cta.js.map +0 -1
  25. package/dist/benchmark/runner.d.ts +0 -19
  26. package/dist/benchmark/runner.d.ts.map +0 -1
  27. package/dist/benchmark/runner.js.map +0 -1
  28. package/dist/benchmark/score.d.ts +0 -26
  29. package/dist/benchmark/score.d.ts.map +0 -1
  30. package/dist/benchmark/score.js.map +0 -1
  31. package/dist/core/report-generator.d.ts +0 -47
  32. package/dist/core/report-generator.d.ts.map +0 -1
  33. package/dist/core/report-generator.js.map +0 -1
  34. package/dist/core/state-engine.d.ts +0 -2
  35. package/dist/core/state-engine.d.ts.map +0 -1
  36. package/dist/core/state-engine.js.map +0 -1
  37. package/dist/engine/state-diff.d.ts +0 -33
  38. package/dist/engine/state-diff.d.ts.map +0 -1
  39. package/dist/engine/state-diff.js.map +0 -1
  40. package/dist/engine/state-machine.d.ts +0 -46
  41. package/dist/engine/state-machine.d.ts.map +0 -1
  42. package/dist/engine/state-machine.js.map +0 -1
  43. package/dist/hardening/patch-generator.d.ts +0 -12
  44. package/dist/hardening/patch-generator.d.ts.map +0 -1
  45. package/dist/hardening/patch-generator.js.map +0 -1
  46. package/dist/index.d.ts +0 -3
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js.map +0 -1
  49. package/dist/proxy/gateway.d.ts +0 -4
  50. package/dist/proxy/gateway.d.ts.map +0 -1
  51. package/dist/proxy/gateway.js.map +0 -1
  52. package/dist/proxy/ghost-write.d.ts +0 -17
  53. package/dist/proxy/ghost-write.d.ts.map +0 -1
  54. package/dist/proxy/ghost-write.js.map +0 -1
  55. package/dist/proxy/interceptor.d.ts +0 -33
  56. package/dist/proxy/interceptor.d.ts.map +0 -1
  57. package/dist/proxy/interceptor.js.map +0 -1
  58. package/dist/scenarios/f1-f5.d.ts +0 -9
  59. package/dist/scenarios/f1-f5.d.ts.map +0 -1
  60. package/dist/scenarios/f1-f5.js.map +0 -1
  61. package/dist/scenarios/types.d.ts +0 -21
  62. package/dist/scenarios/types.d.ts.map +0 -1
  63. package/dist/scenarios/types.js.map +0 -1
  64. package/dist/server/server.d.ts +0 -3
  65. package/dist/server/server.d.ts.map +0 -1
  66. package/dist/server/server.js.map +0 -1
  67. package/dist/server/stdio.d.ts +0 -7
  68. package/dist/server/stdio.d.ts.map +0 -1
  69. package/dist/server/stdio.js.map +0 -1
  70. package/dist/tools/handlers.d.ts +0 -93
  71. package/dist/tools/handlers.d.ts.map +0 -1
  72. package/dist/tools/handlers.js.map +0 -1
  73. package/dist/tools/schemas.d.ts +0 -38
  74. package/dist/tools/schemas.d.ts.map +0 -1
  75. package/dist/tools/schemas.js.map +0 -1
  76. package/dist/version.d.ts +0 -2
  77. package/dist/version.d.ts.map +0 -1
  78. package/dist/version.js.map +0 -1
package/dist/index.js CHANGED
@@ -2,110 +2,462 @@
2
2
  import { createServer } from "./server/server.js";
3
3
  import { startStdio } from "./server/stdio.js";
4
4
  import { runBenchmark, defaultConfig } from "./benchmark/runner.js";
5
- import { formatCTA, buildTelemetry } from "./benchmark/cta.js";
5
+ import { formatCTA, buildTelemetry, COMING_SOON_NOTICE } from "./benchmark/cta.js";
6
+ import { computeScore } from "./benchmark/score.js";
7
+ import { generatePatches } from "./hardening/patch-generator.js";
8
+ import { generateReport } from "./core/report-generator.js";
6
9
  import { ProxyInterceptor } from "./proxy/interceptor.js";
7
- import { createProxyServer } from "./proxy/gateway.js";
10
+ import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
8
11
  import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
9
- import { DEFAULT_WRITE_TOOL_PATTERNS, matchesAnyGlob } from "./engine/state-machine.js";
10
- import { writeFile, mkdir } from "node:fs/promises";
12
+ import { VERSION } from "./version.js";
13
+ import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
14
+ import { randomBytes } from "node:crypto";
15
+ import { writeFileSync, mkdirSync, statSync, lstatSync, existsSync } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
11
17
  const args = process.argv.slice(2);
12
18
  function printHelp() {
13
19
  process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
14
20
  `Usage:\n` +
15
- ` cuonztech-agent-harness Start as an MCP server (stdio)\n` +
16
- ` cuonztech-agent-harness benchmark [opts] Run the fixed reference self-test\n` +
21
+ ` cuonztech-agent-harness Start as an MCP server (stdio, demo/self-test sessions)\n` +
22
+ ` cuonztech-agent-harness --version Print the version (also: -v)\n` +
23
+ ` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
24
+ ` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
25
+ ` "evaluate" is an alias)\n` +
17
26
  ` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
18
27
  ` Run as a transparent chaos proxy\n` +
19
28
  ` in front of a real upstream MCP server\n\n` +
20
29
  `benchmark options:\n` +
21
30
  ` --scenarios F1,F2,... Which scenarios to run (default: all)\n` +
22
- ` --runs <n> Runs per scenario, positive integer (default: 1)\n` +
23
- ` --jitter <ms> Random delay between scripted calls (default: 0)\n\n` +
31
+ ` --runs <n> Runs per scenario, 1-1000 (default: 1). The reference agents\n` +
32
+ ` are deterministic: more runs repeat the same result.\n` +
33
+ ` --jitter <ms> Random delay between the careful agent's calls (default: 0);\n` +
34
+ ` does not change any verdict\n\n` +
24
35
  `proxy options:\n` +
25
36
  ` --upstream-command <cmd> Command to spawn the upstream MCP server (required)\n` +
26
- ` --upstream-args <a,b,c> Comma-separated args for the upstream command\n` +
37
+ ` --upstream-arg <arg> One argument for the upstream command; repeat the flag\n` +
38
+ ` for several. Use this for arguments containing commas.\n` +
39
+ ` --upstream-args <a,b,c> Comma-separated args (cannot contain commas themselves);\n` +
40
+ ` always placed before the --upstream-arg values\n` +
27
41
  ` --upstream-cwd <dir> Working directory for the upstream process\n` +
28
- ` --upstream-env KEY=VAL,... Extra/override env vars for the upstream process\n` +
42
+ ` --upstream-env-var KEY=VALUE One env var for the upstream process; repeatable.\n` +
43
+ ` The value may contain commas.\n` +
44
+ ` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
29
45
  ` --scenario F1..F5 Activate one deterministic scenario\n` +
30
- ` --mode chaos Stochastic error injection instead of a scenario\n` +
31
- ` --error-rate <0..1> Error probability in chaos mode (default: 0)\n` +
32
- ` --write-tools pat1,pat2,... Glob patterns ("*") for which tool names count as\n` +
33
- ` writes (default covers common verbs: write*,\n` +
34
- ` create_*, send_*, submit_*, post_*, update_*, ...\n` +
35
- ` — see DEFAULT_WRITE_TOOL_PATTERNS). Set this if\n` +
36
- ` your upstream's write tools use other naming.\n`);
46
+ ` --mode deterministic|chaos chaos = stochastic error injection (set --error-rate)\n` +
47
+ ` --error-rate <0..1> Error probability in chaos mode\n` +
48
+ ` --retry-after <seconds> retry_after announced by F2's 429 (default: 60;\n` +
49
+ ` only with --scenario F2)\n` +
50
+ ` --write-tools pat1,pat2,... Glob patterns ("*") for tool names that count as\n` +
51
+ ` writes. Replaces the default verb list (write*,\n` +
52
+ ` create_*, send_*, book_*, pay_*, transfer_*, ...).\n` +
53
+ ` --read-tools pat1,pat2,... Extra glob patterns for tool names that count as reads\n` +
54
+ ` --key-fields a,b Argument names that carry an idempotency key\n` +
55
+ ` (default: idempotency_key, any case/separator)\n` +
56
+ ` --audit-tools on|off Expose cuonztech__get_report / cuonztech__get_score\n` +
57
+ ` to the connected agent (default: on). For a\n` +
58
+ ` measurement use off: their descriptions tell the\n` +
59
+ ` agent that it is being tested.\n` +
60
+ ` --report-file <path> Write report + score as JSON to this file: before a\n` +
61
+ ` call goes upstream, before its answer goes to the\n` +
62
+ ` agent, and on shutdown. If the file cannot be\n` +
63
+ ` written, the proxy stops with exit code 1.\n\n` +
64
+ `Options may be written as "--flag value" or "--flag=value".\n\n` +
65
+ `Tool classification in proxy mode: explicit flags first, then the upstream's own\n` +
66
+ `MCP annotations (readOnlyHint/destructiveHint), then name patterns. The result is\n` +
67
+ `printed to stderr at startup; unclassified tools are passed through but not scored.\n\n` +
68
+ `The proxy is a long-running server: its exit code says nothing about the verdict.\n` +
69
+ `Read the verdict from --report-file. Exit code 1 means the run itself broke\n` +
70
+ `(bad arguments, upstream did not start, report file not writable).\n\n` +
71
+ `${COMING_SOON_NOTICE}\n`);
72
+ }
73
+ // Strict flag parsing: an unknown flag, a flag without a value, a repeated
74
+ // single-value flag and a stray positional argument are all errors. The old
75
+ // indexOf()-based lookup silently ignored typos like "--scenaro F1" — the
76
+ // run then tested nothing while looking normal.
77
+ function parseFlags(argv, spec) {
78
+ const values = new Map();
79
+ for (let i = 0; i < argv.length; i++) {
80
+ let token = argv[i];
81
+ // Only where an option is expected — as the VALUE of an option (e.g.
82
+ // --upstream-arg --help) it is passed on, not interpreted.
83
+ if (token === "--help" || token === "-h") {
84
+ return { help: true };
85
+ }
86
+ if (!token.startsWith("--")) {
87
+ return { error: `unexpected argument "${token}".` };
88
+ }
89
+ // "--flag=value" form
90
+ let inlineValue;
91
+ const eq = token.indexOf("=");
92
+ if (eq !== -1 && spec[token.slice(0, eq)]) {
93
+ inlineValue = token.slice(eq + 1);
94
+ token = token.slice(0, eq);
95
+ }
96
+ const kind = spec[token];
97
+ if (!kind) {
98
+ return { error: `unknown option "${token}".` };
99
+ }
100
+ let value;
101
+ if (inlineValue !== undefined) {
102
+ value = inlineValue;
103
+ }
104
+ else {
105
+ value = argv[i + 1];
106
+ i++;
107
+ }
108
+ if (value === undefined) {
109
+ return { error: `option ${token} needs a value.` };
110
+ }
111
+ // "--report-file --scenario F1": the next option is not a value. (Not
112
+ // for --upstream-arg, whose values are handed on and may look like ours.)
113
+ if (inlineValue === undefined && token !== "--upstream-arg" && spec[value]) {
114
+ return { error: `option ${token} needs a value, but is followed by the option ${value}.` };
115
+ }
116
+ // "--report-file --audit-tool off" (a typo) created a file called
117
+ // "--audit-tool". Values for the upstream may start with "--"; use
118
+ // "--flag=--value" if one of ours really has to.
119
+ if (inlineValue === undefined && !token.startsWith("--upstream-") && value.startsWith("--")) {
120
+ return {
121
+ error: `option ${token} needs a value, but is followed by "${value}". (If that is the value, write ${token}=${value}.)`,
122
+ };
123
+ }
124
+ // An empty value is almost always a broken shell variable; treating it
125
+ // as "flag not given" would silently change what the run tests.
126
+ // (--upstream-arg is the exception: an empty string is a legitimate
127
+ // argument to hand to the upstream command.)
128
+ if (value.trim() === "" && token !== "--upstream-arg") {
129
+ return { error: `option ${token} was given an empty value.` };
130
+ }
131
+ const existing = values.get(token);
132
+ if (existing) {
133
+ if (kind === "single") {
134
+ return { error: `option ${token} was given more than once.` };
135
+ }
136
+ existing.push(value);
137
+ }
138
+ else {
139
+ values.set(token, [value]);
140
+ }
141
+ }
142
+ return { values };
143
+ }
144
+ // Set as soon as proxy mode knows where its --report-file goes, so that ANY
145
+ // failed start — including a rejected command line — replaces a report left
146
+ // over from an earlier run instead of leaving its old PASS in place.
147
+ let startupReportFile = null;
148
+ // mkdir -p without { recursive: true }: on Linux pseudo file systems
149
+ // (/proc/…) Node's recursive mkdir never returns and spins at full CPU.
150
+ // Here every level is created once and the first error ends it.
151
+ function ensureDirSync(dir) {
152
+ const missing = [];
153
+ let current = resolve(dir);
154
+ while (!existsSync(current)) {
155
+ missing.push(current);
156
+ const parent = dirname(current);
157
+ if (parent === current)
158
+ break;
159
+ current = parent;
160
+ }
161
+ for (const path of missing.reverse()) {
162
+ try {
163
+ mkdirSync(path);
164
+ }
165
+ catch (err) {
166
+ // Created by someone else in the meantime is fine.
167
+ if (!statSync(path, { throwIfNoEntry: false })?.isDirectory())
168
+ throw err;
169
+ }
170
+ }
171
+ }
172
+ // What the file holds until the first real report: a run that is killed
173
+ // while the upstream is still starting must not leave an older PASS behind.
174
+ function writeStartingPlaceholderSync() {
175
+ writeStartupStateSync({ status: "starting" });
176
+ }
177
+ function writeStartupFailureSync(reason) {
178
+ writeStartupStateSync({ startupError: reason });
179
+ }
180
+ function writeStartupStateSync(extra) {
181
+ if (!startupReportFile)
182
+ return;
183
+ try {
184
+ // Only ever a regular file (or nothing yet): writing "the report" over
185
+ // a directory, a device node or a pipe is never what was meant.
186
+ const existing = statSync(startupReportFile, { throwIfNoEntry: false });
187
+ if (existing && !existing.isFile())
188
+ return;
189
+ if (!existing && lstatSync(startupReportFile, { throwIfNoEntry: false }))
190
+ return;
191
+ ensureDirSync(dirname(startupReportFile));
192
+ writeFileSync(startupReportFile, JSON.stringify({
193
+ harnessVersion: VERSION,
194
+ certification: "UNVERIFIED_FOR_PRODUCTION",
195
+ verdict: "NOT_TESTED",
196
+ ...extra,
197
+ generatedAt: new Date().toISOString(),
198
+ }, null, 2), "utf-8");
199
+ }
200
+ catch {
201
+ // The start already failed; the original error is what gets reported.
202
+ }
203
+ }
204
+ // On Windows a rename over a file that a reader has open fails with
205
+ // EPERM/EACCES/EBUSY; the reader is usually gone a moment later.
206
+ async function renameWithRetry(from, to) {
207
+ const waits = [10, 25, 50, 100];
208
+ for (let attempt = 0;; attempt++) {
209
+ try {
210
+ await rename(from, to);
211
+ return;
212
+ }
213
+ catch (err) {
214
+ const code = err.code;
215
+ if (attempt >= waits.length || !["EPERM", "EACCES", "EBUSY"].includes(code ?? ""))
216
+ throw err;
217
+ await new Promise((resolve) => setTimeout(resolve, waits[attempt]));
218
+ }
219
+ }
220
+ }
221
+ function fail(message) {
222
+ writeStartupFailureSync(message);
223
+ process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
224
+ process.exit(1);
225
+ }
226
+ // Windows device names: writing to "NUL" or "CON" as a relative path can
227
+ // create a real file of that name (or block on a device).
228
+ // Checked for every path segment ("C:\\NUL\\x.json" creates a directory
229
+ // called NUL), and a ":" after the drive letter is refused as well (it
230
+ // addresses an alternate data stream and leaves an empty file behind).
231
+ function isReservedFileName(path) {
232
+ if (process.platform !== "win32")
233
+ return false;
234
+ // "\\?\C:\dir\file" is an ordinary long-path spelling.
235
+ const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
236
+ if (withoutDrive.includes(":"))
237
+ return true;
238
+ // Windows drops a trailing dot or space from a name; the file would not
239
+ // be the one that was asked for.
240
+ if (/[. ]$/.test(withoutDrive))
241
+ return true;
242
+ return withoutDrive
243
+ .split(/[\\/]+/)
244
+ .some((segment) => /^(nul|con|prn|aux|com[1-9]|lpt[1-9])(\..*)?$/i.test(segment));
245
+ }
246
+ // Best-effort lookup of --report-file before the full parse, so a command
247
+ // line that is rejected by the parser still updates that file.
248
+ // Walks the arguments the way the parser does, so that the VALUE of another
249
+ // option ("--upstream-arg --report-file=x") is never mistaken for ours.
250
+ function peekReportFile(argv, spec) {
251
+ for (let i = 0; i < argv.length; i++) {
252
+ const token = argv[i];
253
+ if (token.startsWith("--report-file="))
254
+ return token.slice("--report-file=".length) || null;
255
+ if (token === "--report-file") {
256
+ const value = argv[i + 1];
257
+ // Followed by another option: there is no path (the parser rejects it).
258
+ return value === undefined || value.startsWith("--") ? null : value;
259
+ }
260
+ if (spec[token])
261
+ i++;
262
+ }
263
+ return null;
264
+ }
265
+ function splitList(raw, flag) {
266
+ if (raw === undefined)
267
+ return undefined;
268
+ const parts = raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
269
+ if (parts.length === 0) {
270
+ fail(`${flag} contains no pattern/name.`);
271
+ }
272
+ return parts;
37
273
  }
38
274
  async function main() {
39
275
  const mode = args[0];
40
276
  if (mode === "--help" || mode === "-h" || mode === "help") {
41
277
  printHelp();
42
278
  }
279
+ else if (mode === "--version" || mode === "-v") {
280
+ process.stdout.write(`${VERSION}\n`);
281
+ }
43
282
  else if (mode === "benchmark" || mode === "evaluate") {
44
- await runBenchmarkMode();
283
+ await runBenchmarkMode(args.slice(1));
45
284
  }
46
285
  else if (mode === "proxy") {
47
- await runProxyMode();
286
+ await runProxyMode(args.slice(1));
48
287
  }
49
- else {
288
+ else if (mode === undefined) {
50
289
  // Default: MCP server mode (stdio)
51
290
  const server = createServer();
291
+ // See the proxy: the SDK closes the transport itself on an oversized message.
292
+ server.server.onclose = () => process.exit(0);
52
293
  await startStdio(server);
53
294
  }
295
+ else {
296
+ fail(`unknown command "${mode}". Commands: benchmark, proxy, --help (no command = MCP server).`);
297
+ }
54
298
  }
55
- async function runProxyMode() {
56
- const cmdIdx = args.indexOf("--upstream-command");
57
- const upstreamCommand = cmdIdx !== -1 ? args[cmdIdx + 1] : undefined;
58
- if (!upstreamCommand) {
59
- process.stderr.write("Error: proxy mode requires --upstream-command <cmd>\n" +
60
- "Example: cuonztech-agent-harness proxy --upstream-command node --upstream-args dist/server.js\n");
61
- process.exit(1);
299
+ const PROXY_FLAGS = {
300
+ "--upstream-command": "single",
301
+ "--upstream-args": "single",
302
+ "--upstream-arg": "multi",
303
+ "--upstream-cwd": "single",
304
+ "--upstream-env": "single",
305
+ "--upstream-env-var": "multi",
306
+ "--scenario": "single",
307
+ "--mode": "single",
308
+ "--error-rate": "single",
309
+ "--retry-after": "single",
310
+ "--write-tools": "single",
311
+ "--read-tools": "single",
312
+ "--key-fields": "single",
313
+ "--audit-tools": "single",
314
+ "--report-file": "single",
315
+ };
316
+ function parseEnvPair(pair, flag, into) {
317
+ const eq = pair.indexOf("=");
318
+ const key = eq === -1 ? "" : pair.slice(0, eq).trim();
319
+ if (key === "") {
320
+ fail(`${flag} entry "${pair}" is not KEY=VALUE.`);
321
+ }
322
+ into[key] = pair.slice(eq + 1);
323
+ }
324
+ async function runProxyMode(argv) {
325
+ const parsed = parseFlags(argv, PROXY_FLAGS);
326
+ if ("help" in parsed) {
327
+ printHelp();
62
328
  return;
63
329
  }
64
- const argsIdx = args.indexOf("--upstream-args");
65
- const upstreamArgs = argsIdx !== -1 && args[argsIdx + 1] ? args[argsIdx + 1].split(",") : [];
66
- const cwdIdx = args.indexOf("--upstream-cwd");
67
- const upstreamCwd = cwdIdx !== -1 ? args[cwdIdx + 1] : undefined;
68
- const envIdx = args.indexOf("--upstream-env");
330
+ const peeked = peekReportFile(argv, PROXY_FLAGS);
331
+ if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked)) {
332
+ startupReportFile = peeked;
333
+ }
334
+ if ("error" in parsed)
335
+ fail(parsed.error);
336
+ const one = (name) => parsed.values.get(name)?.[0];
337
+ const many = (name) => parsed.values.get(name) ?? [];
338
+ const upstreamCommand = one("--upstream-command");
339
+ if (!upstreamCommand) {
340
+ fail("proxy mode requires --upstream-command <cmd>\n" +
341
+ "Example: cuonztech-agent-harness proxy --upstream-command node --upstream-arg dist/server.js");
342
+ }
343
+ const upstreamArgs = [
344
+ ...(one("--upstream-args") ? one("--upstream-args").split(",") : []),
345
+ ...many("--upstream-arg"),
346
+ ];
69
347
  const upstreamEnv = {};
70
- if (envIdx !== -1 && args[envIdx + 1]) {
71
- for (const pair of args[envIdx + 1].split(",")) {
72
- const eq = pair.indexOf("=");
73
- if (eq === -1) {
74
- process.stderr.write(`Error: --upstream-env entry "${pair}" is not KEY=VALUE.\n`);
75
- process.exit(1);
76
- return;
77
- }
78
- upstreamEnv[pair.slice(0, eq)] = pair.slice(eq + 1);
348
+ const envList = one("--upstream-env");
349
+ if (envList !== undefined) {
350
+ for (const pair of envList.split(","))
351
+ parseEnvPair(pair, "--upstream-env", upstreamEnv);
352
+ }
353
+ for (const pair of many("--upstream-env-var")) {
354
+ parseEnvPair(pair, "--upstream-env-var", upstreamEnv);
355
+ }
356
+ const scenarioId = one("--scenario");
357
+ if (scenarioId !== undefined && !getScenarioById(scenarioId)) {
358
+ fail(`unknown scenario "${scenarioId}". Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
359
+ }
360
+ const modeRaw = one("--mode");
361
+ if (modeRaw !== undefined && modeRaw !== "deterministic" && modeRaw !== "chaos") {
362
+ fail(`--mode must be "deterministic" or "chaos", got "${modeRaw}".`);
363
+ }
364
+ const chaosMode = modeRaw === "chaos" ? "chaos" : "deterministic";
365
+ const errorRateRaw = one("--error-rate");
366
+ let errorRate = 0.0;
367
+ if (errorRateRaw !== undefined) {
368
+ if (!/^(\d+(\.\d+)?|\.\d+)$/.test(errorRateRaw) || Number(errorRateRaw) > 1) {
369
+ fail(`--error-rate must be a number between 0 and 1, got "${errorRateRaw}".`);
79
370
  }
371
+ if (chaosMode !== "chaos") {
372
+ fail("--error-rate only has an effect together with --mode chaos.");
373
+ }
374
+ errorRate = Number(errorRateRaw);
80
375
  }
81
- const writeToolsIdx = args.indexOf("--write-tools");
82
- const writeToolPatterns = writeToolsIdx !== -1 && args[writeToolsIdx + 1]
83
- ? args[writeToolsIdx + 1].split(",")
84
- : undefined;
85
- const scenarioIdx = args.indexOf("--scenario");
86
- const scenarioId = scenarioIdx !== -1 ? args[scenarioIdx + 1] : undefined;
87
- if (scenarioId && !getScenarioById(scenarioId)) {
88
- process.stderr.write(`Error: unknown scenario "${scenarioId}". Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}\n`);
89
- process.exit(1);
90
- return;
376
+ if (scenarioId !== undefined && chaosMode === "chaos") {
377
+ fail("--scenario and --mode chaos cannot be combined: a scenario is deterministic, " +
378
+ "and with both set the chaos mode would never inject anything.");
91
379
  }
92
- const modeIdx = args.indexOf("--mode");
93
- const chaosModeRequested = modeIdx !== -1 ? args[modeIdx + 1] : undefined;
94
- const chaosMode = chaosModeRequested === "chaos" ? "chaos" : "deterministic";
95
- const errorRateIdx = args.indexOf("--error-rate");
96
- const errorRate = errorRateIdx !== -1 && args[errorRateIdx + 1]
97
- ? parseFloat(args[errorRateIdx + 1])
98
- : 0.0;
99
- if (chaosMode === "chaos" && errorRateIdx === -1) {
100
- process.stderr.write("[cuonztech-agent-harness] WARNING: --mode chaos without --error-rate defaults " +
101
- "to a 0% error rate — this run will inject nothing. Pass e.g. --error-rate 0.2 " +
102
- "to actually trigger stochastic failures.\n");
103
- }
104
- if (scenarioId || chaosMode === "chaos") {
105
- process.stderr.write("[cuonztech-agent-harness] WARNING: chaos/scenario injection is active — " +
106
- "the agent will sometimes receive errors or duplicate successes that do " +
107
- "NOT reflect what actually happened upstream. Only point --upstream-command " +
108
- "at a disposable test/staging backend, never production.\n");
380
+ const retryAfterRaw = one("--retry-after");
381
+ let retryAfterSec;
382
+ if (retryAfterRaw !== undefined) {
383
+ const value = Number(retryAfterRaw);
384
+ if (!/^(\d+(\.\d+)?|\.\d+)$/.test(retryAfterRaw) || value <= 0 || value > 86_400) {
385
+ fail(`--retry-after must be a positive number of seconds (at most 86400), got "${retryAfterRaw}".`);
386
+ }
387
+ if (scenarioId !== "F2") {
388
+ fail("--retry-after only has an effect together with --scenario F2.");
389
+ }
390
+ retryAfterSec = value;
391
+ }
392
+ const auditToolsRaw = one("--audit-tools")?.toLowerCase();
393
+ if (auditToolsRaw !== undefined && auditToolsRaw !== "on" && auditToolsRaw !== "off") {
394
+ fail(`--audit-tools must be "on" or "off", got "${auditToolsRaw}".`);
395
+ }
396
+ const auditTools = auditToolsRaw !== "off";
397
+ let reportFile = one("--report-file");
398
+ const writeToolPatterns = splitList(one("--write-tools"), "--write-tools");
399
+ const readToolPatterns = splitList(one("--read-tools"), "--read-tools");
400
+ const keyFields = splitList(one("--key-fields"), "--key-fields");
401
+ const upstreamCwd = one("--upstream-cwd");
402
+ if (upstreamCwd !== undefined) {
403
+ const info = await stat(upstreamCwd).catch(() => null);
404
+ if (!info || !info.isDirectory()) {
405
+ fail(`--upstream-cwd "${upstreamCwd}" is not an existing directory.`);
406
+ }
407
+ }
408
+ if (reportFile !== undefined) {
409
+ if (isReservedFileName(reportFile)) {
410
+ fail(`--report-file "${reportFile}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.`);
411
+ }
412
+ const info = await stat(reportFile).catch(() => null);
413
+ if (info?.isDirectory()) {
414
+ fail(`--report-file "${reportFile}" is a directory, not a file.`);
415
+ }
416
+ // The report is written to a temp file and renamed over the target —
417
+ // which would replace a device node such as /dev/null by a regular file.
418
+ if (info && !info.isFile()) {
419
+ fail(`--report-file "${reportFile}" exists and is not a regular file.`);
420
+ }
421
+ let dangling = false;
422
+ try {
423
+ dangling = !info && lstatSync(reportFile, { throwIfNoEntry: false }) !== undefined;
424
+ }
425
+ catch (err) {
426
+ fail(`--report-file "${reportFile}" is not a usable path: ${err instanceof Error ? err.message : String(err)}`);
427
+ }
428
+ if (dangling) {
429
+ fail(`--report-file "${reportFile}" is a symbolic link to a path that does not exist.`);
430
+ }
431
+ // Write through a symlink instead of replacing the link itself.
432
+ if (info) {
433
+ reportFile = await realpath(reportFile).catch(() => reportFile);
434
+ }
435
+ }
436
+ const reportTarget = reportFile;
437
+ const log = (line) => {
438
+ process.stderr.write(`[cuonztech-agent-harness] ${line}\n`);
439
+ };
440
+ // A tool name comes from the upstream: a line break in it must not be
441
+ // able to fake further lines of this log.
442
+ const shown = (name) => name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
443
+ const chaosInjectsNothing = chaosMode === "chaos" && errorRate === 0;
444
+ if (chaosInjectsNothing) {
445
+ log("WARNING: --mode chaos with a 0% error rate — this run will inject nothing. " +
446
+ "Pass e.g. --error-rate 0.2 to actually trigger stochastic failures.");
447
+ }
448
+ if (scenarioId === "F1") {
449
+ log("WARNING: scenario F1 really executes the agent's first write upstream and then " +
450
+ "reports a timeout to the agent. Only point --upstream-command at a disposable " +
451
+ "test/staging backend, never production.");
452
+ }
453
+ else if (scenarioId || (chaosMode === "chaos" && !chaosInjectsNothing)) {
454
+ log("WARNING: failure injection is active — the agent will receive errors for calls " +
455
+ "that were never sent upstream. Every other write is executed for real. Only " +
456
+ "point --upstream-command at a disposable test/staging backend, never production.");
457
+ }
458
+ if (!scenarioId && chaosMode !== "chaos") {
459
+ log("NOTE: no --scenario and no chaos mode — calls are passed through unchanged. A " +
460
+ "result from this run is a baseline, not a resilience test.");
109
461
  }
110
462
  const interceptor = new ProxyInterceptor({
111
463
  upstream: {
@@ -118,39 +470,256 @@ async function runProxyMode() {
118
470
  mode: chaosMode,
119
471
  errorRate,
120
472
  writeToolPatterns,
473
+ readToolPatterns,
474
+ keyFields,
475
+ retryAfterSec,
121
476
  });
122
- await interceptor.connectUpstream();
123
- // If NOT ONE of the upstream's real tools matches any configured
124
- // write-tool pattern, ghost-write/duplicate-dispatch detection (F1/F4) and
125
- // idempotency-key tracking will NEVER trigger for this upstream — the
126
- // session stays silently inert while get_report/get_score can still print
127
- // a misleadingly clean 100/100. An adversarial test against the published
128
- // package found exactly this against a realistically-named tool. Warn
129
- // loudly instead of staying silent, even though DEFAULT_WRITE_TOOL_PATTERNS
130
- // is now broader than just "write*".
131
- const effectivePatterns = writeToolPatterns && writeToolPatterns.length > 0
132
- ? writeToolPatterns
133
- : DEFAULT_WRITE_TOOL_PATTERNS;
477
+ // From here on a signal must end the upstream child as well, and the
478
+ // report file must not keep an older run's verdict while we are starting.
479
+ let onSignal = async () => {
480
+ await interceptor.disconnectUpstream().catch(() => undefined);
481
+ process.exit(1);
482
+ };
483
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
484
+ process.on(signal, () => void onSignal());
485
+ }
486
+ // The client stopped reading (it closed its end or was killed while
487
+ // answers were on their way). Unhandled, that EPIPE crashed the proxy with
488
+ // a stack trace and could leave the upstream running.
489
+ process.stdout.on("error", () => void onSignal());
490
+ // The client going away while the upstream is still starting. A
491
+ // "readable" listener notices the end of stdin without consuming what the
492
+ // client has already sent (its initialize request arrives during startup).
493
+ const onEarlyReadable = () => undefined;
494
+ const onEarlyEnd = () => void onSignal();
495
+ process.stdin.on("readable", onEarlyReadable);
496
+ process.stdin.on("end", onEarlyEnd);
497
+ writeStartingPlaceholderSync();
498
+ try {
499
+ await interceptor.connectUpstream();
500
+ }
501
+ catch (err) {
502
+ // A hanging upstream is still running at this point.
503
+ await interceptor.disconnectUpstream().catch(() => undefined);
504
+ const tail = interceptor.getUpstreamStderrTail().trim();
505
+ const reason = `could not start or connect to the upstream MCP server ` +
506
+ `("${upstreamCommand}${upstreamArgs.length > 0 ? " " + upstreamArgs.join(" ") : ""}"): ` +
507
+ `${err instanceof Error ? err.message : String(err)}`;
508
+ fail(reason +
509
+ (tail.length > 0
510
+ ? `\nUpstream stderr:\n${tail}`
511
+ : "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
512
+ }
134
513
  const upstreamTools = interceptor.getUpstreamTools();
135
- const anyToolMatches = upstreamTools.some((t) => matchesAnyGlob(t.name, effectivePatterns));
136
- if (upstreamTools.length > 0 && !anyToolMatches) {
137
- process.stderr.write(`[cuonztech-agent-harness] WARNING: none of the upstream's ${upstreamTools.length} ` +
138
- `tool(s) (${upstreamTools.map((t) => t.name).join(", ")}) match any write-tool ` +
139
- `pattern (currently: ${effectivePatterns.join(", ")}). Ghost-write/duplicate-dispatch ` +
140
- `detection will NEVER trigger for this upstream, and real duplicate writes will go ` +
141
- `completely undetected. If your write tools use different naming, pass e.g. ` +
142
- `--write-tools "yourprefix_*".\n`);
514
+ const collisions = findToolNameCollisions(upstreamTools);
515
+ if (auditTools && collisions.length > 0) {
516
+ await interceptor.disconnectUpstream();
517
+ fail(`the upstream already has tool(s) named ${collisions.join(", ")}, which collide with ` +
518
+ "the harness's own audit tools. Start with --audit-tools off (and --report-file to " +
519
+ "collect the result).");
520
+ }
521
+ // Say what each upstream tool was taken for. Scoring depends entirely on
522
+ // this, and a wrong or missing classification is otherwise invisible.
523
+ const classification = interceptor.getToolClassification();
524
+ log(`Upstream tools (${classification.length}):`);
525
+ for (const entry of classification) {
526
+ log(` ${entry.class.padEnd(12)} ${shown(entry.name)} (${entry.source})`);
527
+ }
528
+ for (const entry of classification.filter((c) => c.class === "unclassified")) {
529
+ log(`WARNING: tool "${shown(entry.name)}" could not be classified as read or write. Its calls ` +
530
+ "are passed through but never trigger a scenario and are not scored — if it writes, " +
531
+ `duplicates through it stay undetected. Classify it with --write-tools "${shown(entry.name)}" ` +
532
+ `or --read-tools "${shown(entry.name)}".`);
533
+ }
534
+ if (upstreamTools.length > 0 && !classification.some((c) => c.class === "write")) {
535
+ log(`WARNING: none of the upstream's ${upstreamTools.length} tool(s) ` +
536
+ `(${upstreamTools.map((t) => shown(t.name)).join(", ")}) is classified as a write. ` +
537
+ "Scenarios will NEVER trigger for this upstream and every report will read NOT_TESTED. " +
538
+ 'If your write tools use different naming, pass e.g. --write-tools "yourprefix_*".');
539
+ }
540
+ const keyCapable = interceptor.getKeyCapableTools();
541
+ log(keyCapable.length > 0
542
+ ? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
543
+ : "NOTE: no upstream tool has an argument recognized as an idempotency key " +
544
+ "(looked for: " +
545
+ (keyFields ?? ["idempotency_key"]).join(", ") +
546
+ "). " +
547
+ (keyFields
548
+ ? "Check the names given to --key-fields against the tools' input schemas."
549
+ : "If your tools use another name (request_id, client_token, ...), pass " +
550
+ "--key-fields — otherwise a retry under a new such value looks like a different operation."));
551
+ log("Only tools are proxied. The upstream's resources, prompts and notifications are not forwarded.");
552
+ if (auditTools && (scenarioId || chaosMode === "chaos")) {
553
+ log("NOTE: the audit tools (cuonztech__get_report, cuonztech__get_score) are visible to " +
554
+ "the connected agent and reveal what really happened upstream. For a measurement " +
555
+ "the agent must not be able to peek: use --audit-tools off with --report-file.");
556
+ }
557
+ if (!auditTools && !reportFile) {
558
+ log("WARNING: --audit-tools off without --report-file — this run has no way to hand " +
559
+ "out its result.");
560
+ }
561
+ // --report-file: rewritten after every call (a proxy is usually killed,
562
+ // not shut down politely, so waiting for exit would lose the result).
563
+ // Writes are chained so they cannot overlap, and go through a temp file
564
+ // so a reader never sees a half-written report.
565
+ // Rewrites requested while one is running are folded into a single
566
+ // follow-up (after 4000 quick calls the old one-rewrite-per-call queue
567
+ // took 89 s to drain on shutdown).
568
+ let writing = null;
569
+ let dirty = false;
570
+ let reportWriteFailed = false;
571
+ // Once the proxy is serving: a report that cannot be written ends the run
572
+ // BEFORE the agent gets the next answer. Carrying on left a file that
573
+ // still said PASS while duplicates were being executed (a reader holding
574
+ // the file open on Windows, a full disk, a read-only directory).
575
+ let stopOnWriteFailure = false;
576
+ let stopping = false;
577
+ const stopForStaleReport = async () => {
578
+ if (!stopping) {
579
+ stopping = true;
580
+ await interceptor.disconnectUpstream().catch(() => undefined);
581
+ process.exit(1);
582
+ }
583
+ return new Promise(() => undefined);
584
+ };
585
+ const writeOnce = async () => {
586
+ if (!reportTarget)
587
+ return;
588
+ try {
589
+ const report = generateReport(interceptor.getSession());
590
+ const score = computeScore([report]);
591
+ const patches = generatePatches([report], score);
592
+ const body = JSON.stringify({
593
+ harnessVersion: VERSION,
594
+ // Constant: a self-test, never a certificate.
595
+ certification: "UNVERIFIED_FOR_PRODUCTION",
596
+ // The audit verdict (PASS / FAIL / NOT_TESTED), same as report.verdict.
597
+ verdict: report.verdict,
598
+ report,
599
+ score,
600
+ patches,
601
+ toolClassification: classification,
602
+ generatedAt: new Date().toISOString(),
603
+ }, null, 2);
604
+ ensureDirSync(dirname(reportTarget));
605
+ // A fresh, unpredictable temp name, created exclusively: a fixed
606
+ // "<file>.tmp" was followed if someone had put a symlink there, and
607
+ // was shared by two proxies writing to the same file.
608
+ const tmp = `${reportTarget}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
609
+ try {
610
+ await writeFile(tmp, body, { encoding: "utf-8", flag: "wx" });
611
+ await renameWithRetry(tmp, reportTarget);
612
+ }
613
+ catch {
614
+ await unlink(tmp).catch(() => undefined);
615
+ // Second way in: write the file itself. Not atomic (a reader may
616
+ // catch it half-written), but current — and it works where the
617
+ // rename does not: a reader holding the file open on Windows, a
618
+ // directory that takes no new files, a name too long for the temp
619
+ // file. Only ever into a regular file.
620
+ const target = lstatSync(reportTarget, { throwIfNoEntry: false });
621
+ if (target && !target.isFile()) {
622
+ throw new Error("the target is no longer a regular file");
623
+ }
624
+ await writeFile(reportTarget, body, "utf-8");
625
+ }
626
+ if (reportWriteFailed) {
627
+ reportWriteFailed = false;
628
+ log(`--report-file "${reportTarget}" is being written again and is up to date.`);
629
+ }
630
+ }
631
+ catch (err) {
632
+ if (!reportWriteFailed) {
633
+ reportWriteFailed = true;
634
+ log(`ERROR: could not write --report-file "${reportTarget}": ${err}. The file is now ` +
635
+ "STALE: it still shows an earlier state of this run (or none). Do not use its verdict." +
636
+ (stopOnWriteFailure ? " Stopping with exit code 1; no further answer goes to the agent." : ""));
637
+ }
638
+ if (stopOnWriteFailure)
639
+ await stopForStaleReport();
640
+ }
641
+ };
642
+ const writeReportFile = () => {
643
+ if (!reportTarget)
644
+ return Promise.resolve();
645
+ dirty = true;
646
+ if (!writing) {
647
+ writing = (async () => {
648
+ while (dirty) {
649
+ dirty = false;
650
+ await writeOnce();
651
+ }
652
+ })().finally(() => {
653
+ writing = null;
654
+ });
655
+ }
656
+ return writing;
657
+ };
658
+ if (reportFile) {
659
+ interceptor.onCallRecorded = () => writeReportFile();
660
+ await writeReportFile();
661
+ if (reportWriteFailed) {
662
+ await interceptor.disconnectUpstream();
663
+ fail(`cannot write --report-file "${reportFile}".`);
664
+ }
665
+ stopOnWriteFailure = true;
143
666
  }
667
+ interceptor.onUpstreamLost = (stderrTail) => {
668
+ log("ERROR: the upstream MCP server process exited or closed its connection. Every " +
669
+ "further tool call is answered with UPSTREAM_UNAVAILABLE; the proxy does not " +
670
+ "restart it.");
671
+ if (stderrTail.trim().length > 0) {
672
+ log(`Last upstream stderr output:\n${stderrTail.trimEnd()}`);
673
+ }
674
+ };
675
+ let shuttingDown = false;
144
676
  const shutdown = async () => {
677
+ if (shuttingDown)
678
+ return;
679
+ shuttingDown = true;
680
+ await writeReportFile();
145
681
  await interceptor.disconnectUpstream();
146
- process.exit(0);
682
+ // Calls that were still open have just been ended by the disconnect and
683
+ // are recording themselves; let that finish and write once more, so no
684
+ // write is cut off by the exit (it left an empty temp file behind).
685
+ await new Promise((resolve) => setImmediate(resolve));
686
+ await writeReportFile();
687
+ // A report file that could not be written at the end is stale; that
688
+ // must not look like a clean exit.
689
+ process.exit(reportWriteFailed ? 1 : 0);
690
+ };
691
+ onSignal = shutdown;
692
+ process.stdin.off("readable", onEarlyReadable);
693
+ process.stdin.off("end", onEarlyEnd);
694
+ // The client closing our stdin is the normal way an MCP stdio session
695
+ // ends; without this the proxy (and its upstream child) would linger.
696
+ process.stdin.on("end", shutdown);
697
+ process.stdin.on("close", shutdown);
698
+ startupReportFile = null;
699
+ const server = createProxyServer(interceptor, { auditTools });
700
+ // The SDK closes the transport on its own when a single message exceeds
701
+ // its buffer (about 10 MB). Without this the proxy stayed alive, deaf,
702
+ // with its upstream child still running.
703
+ server.onclose = () => {
704
+ log("The connection to the client was closed (stdin ended, or a message was too large for the MCP transport). Shutting down.");
705
+ void shutdown();
147
706
  };
148
- process.on("SIGINT", shutdown);
149
- process.on("SIGTERM", shutdown);
150
- const server = createProxyServer(interceptor);
151
707
  await startStdio(server);
152
708
  }
153
- async function runBenchmarkMode() {
709
+ const BENCHMARK_FLAGS = {
710
+ "--runs": "single",
711
+ "--jitter": "single",
712
+ "--scenarios": "single",
713
+ };
714
+ async function runBenchmarkMode(argv) {
715
+ const parsed = parseFlags(argv, BENCHMARK_FLAGS);
716
+ if ("help" in parsed) {
717
+ printHelp();
718
+ return;
719
+ }
720
+ if ("error" in parsed)
721
+ fail(parsed.error);
722
+ const one = (name) => parsed.values.get(name)?.[0];
154
723
  const config = defaultConfig();
155
724
  // Strict integer parsing: parseInt() alone is too lenient (parseInt("1.5")
156
725
  // silently truncates to 1 instead of being rejected) — only a string
@@ -158,85 +727,105 @@ async function runBenchmarkMode() {
158
727
  function parseStrictInt(raw) {
159
728
  return /^-?\d+$/.test(raw) ? parseInt(raw, 10) : null;
160
729
  }
161
- // Parse optional flags
162
- const runsIdx = args.indexOf("--runs");
163
- if (runsIdx !== -1 && args[runsIdx + 1]) {
164
- const parsed = parseStrictInt(args[runsIdx + 1]);
165
- if (parsed === null || parsed < 1) {
166
- // A non-positive/NaN/non-integer value used to silently run ZERO
167
- // scenarios while still printing a fake "Overall: 100/100 [EXCELLENT]"
168
- // (computeScore's "no data = perfect" default, which is correct for a
169
- // real session with genuinely nothing to flag, but dishonest here —
170
- // nothing was ever tested) and persisting that 100/100 into
171
- // .cuonztech/benchmark-report.json for anything (e.g. a CI gate) to
172
- // pick up as real.
173
- process.stderr.write(`Error: --runs must be a positive integer, got "${args[runsIdx + 1]}".\n`);
174
- process.exit(1);
175
- return;
730
+ const runsRaw = one("--runs");
731
+ if (runsRaw !== undefined) {
732
+ const value = parseStrictInt(runsRaw);
733
+ if (value === null || value < 1 || value > 1000) {
734
+ fail(`--runs must be a positive integer (at most 1000), got "${runsRaw}".`);
176
735
  }
177
- config.runsPerScenario = parsed;
736
+ config.runsPerScenario = value;
178
737
  }
179
- const jitterIdx = args.indexOf("--jitter");
180
- if (jitterIdx !== -1 && args[jitterIdx + 1]) {
181
- const parsed = parseStrictInt(args[jitterIdx + 1]);
182
- if (parsed === null || parsed < 0) {
183
- process.stderr.write(`Error: --jitter must be a non-negative integer (ms), got "${args[jitterIdx + 1]}".\n`);
184
- process.exit(1);
185
- return;
738
+ const jitterRaw = one("--jitter");
739
+ if (jitterRaw !== undefined) {
740
+ const value = parseStrictInt(jitterRaw);
741
+ if (value === null || value < 0 || value > 60_000) {
742
+ fail(`--jitter must be a non-negative integer (ms, at most 60000), got "${jitterRaw}".`);
186
743
  }
187
- config.jitterMs = parsed;
744
+ config.jitterMs = value;
188
745
  }
189
- const scenariosIdx = args.indexOf("--scenarios");
190
- if (scenariosIdx !== -1 && args[scenariosIdx + 1]) {
191
- config.scenarios = args[scenariosIdx + 1].split(",");
746
+ const scenariosRaw = one("--scenarios");
747
+ if (scenariosRaw !== undefined) {
748
+ config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim()))];
192
749
  }
193
750
  const unknown = config.scenarios.filter((id) => !getScenarioById(id));
194
751
  if (unknown.length > 0) {
195
- process.stderr.write(`Error: unknown scenario(s) "${unknown.join(", ")}". ` +
196
- `Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}\n`);
197
- process.exit(1);
198
- return;
752
+ fail(`unknown scenario(s) "${unknown.join(", ")}". ` +
753
+ `Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
199
754
  }
200
755
  process.stdout.write(`CuonzTech Agent-Harness Benchmark\n`);
201
- process.stdout.write("NOTE: this replays a fixed built-in reference call sequence — it does NOT\n" +
202
- "connect to or measure your own agent. It is a self-test of the harness's\n" +
203
- "scenario/scoring logic, useful as a reference point, not a verdict on your\n" +
204
- "system. To score YOUR agent, connect it to `proxy` mode or the MCP server's\n" +
205
- "`start_session`/`execute_call`/`get_score` tools instead.\n\n");
756
+ process.stdout.write("NOTE: this replays two fixed built-in reference agents — it does NOT\n" +
757
+ "connect to or measure your own agent. It is a self-test of the harness:\n" +
758
+ "it checks that each reference agent gets the verdict expected for it. To\n" +
759
+ "score YOUR agent, put the harness in front of your MCP server with `proxy` mode.\n\n");
206
760
  process.stdout.write(`Scenarios: ${config.scenarios.join(", ")}\n`);
207
761
  process.stdout.write(`Runs per scenario: ${config.runsPerScenario}\n`);
208
762
  process.stdout.write(`Jitter: ${config.jitterMs}ms\n\n`);
209
763
  const result = await runBenchmark(config);
210
- // Output score to stdout
211
- process.stdout.write(result.markdownScore + "\n");
212
- process.stdout.write(formatCTA(result.score) + "\n\n");
213
- // Output patches if any
214
- if (result.patches.length > 0) {
215
- process.stdout.write(result.markdownPatches + "\n");
764
+ const printRun = (title, run) => {
765
+ process.stdout.write(`${"#".repeat(60)}\n${title}\n${"#".repeat(60)}\n\n`);
766
+ process.stdout.write(run.markdownScore + "\n");
767
+ process.stdout.write(formatCTA(run.score) + "\n\n");
768
+ if (run.patches.length > 0) {
769
+ process.stdout.write(run.markdownPatches + "\n");
770
+ }
771
+ };
772
+ printRun("Reference agent 1/2: CAREFUL (checks after a failure, never re-sends blindly)", result.careful);
773
+ printRun("Reference agent 2/2: NAIVE (re-sends every write immediately, no key)", result.naive);
774
+ if (result.selfTest.passed) {
775
+ const separating = config.scenarios.filter((id) => ["F1", "F2", "F4"].includes(id));
776
+ process.stdout.write("Self-test PASSED: every run got the verdict expected for its reference agent.\n" +
777
+ (separating.length > 0
778
+ ? `The naive agent is expected to fail in ${separating.join(", ")} and did.\n`
779
+ : "Note: in the selected scenarios (F3/F5) the failed attempt never executes, so an " +
780
+ "immediate retry is harmless and BOTH reference agents are expected to pass — " +
781
+ "this selection does not show the harness telling them apart.\n"));
782
+ }
783
+ else {
784
+ process.stdout.write("Self-test FAILED — the harness did not judge its own reference agents as expected:\n" +
785
+ result.selfTest.mismatches.map((m) => ` - ${m}`).join("\n") +
786
+ "\n");
216
787
  }
217
788
  // Save JSON report
218
789
  const reportDir = ".cuonztech";
219
- await mkdir(reportDir, { recursive: true });
220
790
  const jsonPath = `${reportDir}/benchmark-report.json`;
221
- const telemetry = buildTelemetry(result.score, result.patches);
222
- await writeFile(jsonPath, JSON.stringify({
223
- ...telemetry,
224
- score: result.score,
225
- patches: result.patches.map((p) => ({
791
+ const serializeRun = (run) => ({
792
+ ...buildTelemetry(run.score, run.patches),
793
+ score: run.score,
794
+ patches: run.patches.map((p) => ({
226
795
  category: p.category,
227
796
  severity: p.severity,
228
797
  trigger: p.trigger,
229
798
  promptSnippet: p.promptSnippet,
230
799
  })),
231
- reports: result.reports,
232
- generatedAt: new Date().toISOString(),
233
- }, null, 2), "utf-8");
234
- process.stdout.write(`\nReport saved to ${jsonPath}\n`);
235
- // Exit code based on score
236
- process.exit(result.score.overall >= 70 ? 0 : 1);
800
+ reports: run.reports,
801
+ });
802
+ // The exit code reflects the self-test only; a report that cannot be
803
+ // written is a warning, not a different test result.
804
+ try {
805
+ await mkdir(reportDir, { recursive: true });
806
+ await writeFile(jsonPath, JSON.stringify({
807
+ // Constant, as in the proxy's report file. PASS/FAIL per reference
808
+ // agent is in careful.score.verdict / naive.score.verdict.
809
+ certification: "UNVERIFIED_FOR_PRODUCTION",
810
+ isReferenceRun: true,
811
+ note: "Self-test of the harness with two built-in reference agents. Neither score " +
812
+ "says anything about your own agent.",
813
+ selfTest: result.selfTest,
814
+ careful: serializeRun(result.careful),
815
+ naive: serializeRun(result.naive),
816
+ generatedAt: new Date().toISOString(),
817
+ }, null, 2), "utf-8");
818
+ process.stdout.write(`\nReport saved to ${jsonPath}\n`);
819
+ }
820
+ catch (err) {
821
+ process.stderr.write(`Warning: could not write ${jsonPath}: ${err instanceof Error ? err.message : String(err)}\n`);
822
+ }
823
+ process.stdout.write(`\n${COMING_SOON_NOTICE}\n`);
824
+ // Exit code reflects the self-test, not a score: 0 = the harness judged
825
+ // both reference agents as expected.
826
+ process.exit(result.selfTest.passed ? 0 : 1);
237
827
  }
238
828
  main().catch((err) => {
239
829
  process.stderr.write(`Fatal: ${err}\n`);
240
830
  process.exit(1);
241
831
  });
242
- //# sourceMappingURL=index.js.map