cuonztech-agent-harness 0.5.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.
- package/README.md +54 -16
- package/dist/benchmark/cta.js +3 -3
- package/dist/benchmark/score.js +6 -3
- package/dist/core/report-generator.js +279 -30
- package/dist/engine/state-machine.js +32 -4
- package/dist/hardening/patch-generator.js +14 -5
- package/dist/index.js +270 -55
- package/dist/proxy/gateway.js +2 -1
- package/dist/proxy/interceptor.js +141 -17
- package/dist/scenarios/f1-f5.js +1 -1
- package/dist/server/server.js +67 -14
- package/dist/tools/schemas.js +5 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,15 +10,17 @@ import { ProxyInterceptor } from "./proxy/interceptor.js";
|
|
|
10
10
|
import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
|
|
11
11
|
import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
|
-
import { writeFile, mkdir, rename, stat } from "node:fs/promises";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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";
|
|
16
17
|
const args = process.argv.slice(2);
|
|
17
18
|
function printHelp() {
|
|
18
19
|
process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
|
|
19
20
|
`Usage:\n` +
|
|
20
21
|
` cuonztech-agent-harness Start as an MCP server (stdio, demo/self-test sessions)\n` +
|
|
21
|
-
` cuonztech-agent-harness --version Print the version\n` +
|
|
22
|
+
` cuonztech-agent-harness --version Print the version (also: -v)\n` +
|
|
23
|
+
` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
|
|
22
24
|
` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
|
|
23
25
|
` "evaluate" is an alias)\n` +
|
|
24
26
|
` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
|
|
@@ -41,7 +43,7 @@ function printHelp() {
|
|
|
41
43
|
` The value may contain commas.\n` +
|
|
42
44
|
` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
|
|
43
45
|
` --scenario F1..F5 Activate one deterministic scenario\n` +
|
|
44
|
-
` --mode deterministic|chaos chaos = stochastic error injection (
|
|
46
|
+
` --mode deterministic|chaos chaos = stochastic error injection (set --error-rate)\n` +
|
|
45
47
|
` --error-rate <0..1> Error probability in chaos mode\n` +
|
|
46
48
|
` --retry-after <seconds> retry_after announced by F2's 429 (default: 60;\n` +
|
|
47
49
|
` only with --scenario F2)\n` +
|
|
@@ -52,15 +54,20 @@ function printHelp() {
|
|
|
52
54
|
` --key-fields a,b Argument names that carry an idempotency key\n` +
|
|
53
55
|
` (default: idempotency_key, any case/separator)\n` +
|
|
54
56
|
` --audit-tools on|off Expose cuonztech__get_report / cuonztech__get_score\n` +
|
|
55
|
-
` to the connected agent (default: on)\n` +
|
|
56
|
-
`
|
|
57
|
-
`
|
|
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` +
|
|
58
64
|
`Options may be written as "--flag value" or "--flag=value".\n\n` +
|
|
59
65
|
`Tool classification in proxy mode: explicit flags first, then the upstream's own\n` +
|
|
60
66
|
`MCP annotations (readOnlyHint/destructiveHint), then name patterns. The result is\n` +
|
|
61
67
|
`printed to stderr at startup; unclassified tools are passed through but not scored.\n\n` +
|
|
62
68
|
`The proxy is a long-running server: its exit code says nothing about the verdict.\n` +
|
|
63
|
-
`Read the verdict from --report-file
|
|
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` +
|
|
64
71
|
`${COMING_SOON_NOTICE}\n`);
|
|
65
72
|
}
|
|
66
73
|
// Strict flag parsing: an unknown flag, a flag without a value, a repeated
|
|
@@ -101,6 +108,19 @@ function parseFlags(argv, spec) {
|
|
|
101
108
|
if (value === undefined) {
|
|
102
109
|
return { error: `option ${token} needs a value.` };
|
|
103
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
|
+
}
|
|
104
124
|
// An empty value is almost always a broken shell variable; treating it
|
|
105
125
|
// as "flag not given" would silently change what the run tests.
|
|
106
126
|
// (--upstream-arg is the exception: an empty string is a legitimate
|
|
@@ -125,18 +145,55 @@ function parseFlags(argv, spec) {
|
|
|
125
145
|
// failed start — including a rejected command line — replaces a report left
|
|
126
146
|
// over from an earlier run instead of leaving its old PASS in place.
|
|
127
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
|
+
}
|
|
128
177
|
function writeStartupFailureSync(reason) {
|
|
178
|
+
writeStartupStateSync({ startupError: reason });
|
|
179
|
+
}
|
|
180
|
+
function writeStartupStateSync(extra) {
|
|
129
181
|
if (!startupReportFile)
|
|
130
182
|
return;
|
|
131
183
|
try {
|
|
132
|
-
|
|
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())
|
|
133
188
|
return;
|
|
134
|
-
|
|
189
|
+
if (!existing && lstatSync(startupReportFile, { throwIfNoEntry: false }))
|
|
190
|
+
return;
|
|
191
|
+
ensureDirSync(dirname(startupReportFile));
|
|
135
192
|
writeFileSync(startupReportFile, JSON.stringify({
|
|
136
193
|
harnessVersion: VERSION,
|
|
137
194
|
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
138
195
|
verdict: "NOT_TESTED",
|
|
139
|
-
|
|
196
|
+
...extra,
|
|
140
197
|
generatedAt: new Date().toISOString(),
|
|
141
198
|
}, null, 2), "utf-8");
|
|
142
199
|
}
|
|
@@ -144,6 +201,23 @@ function writeStartupFailureSync(reason) {
|
|
|
144
201
|
// The start already failed; the original error is what gets reported.
|
|
145
202
|
}
|
|
146
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
|
+
}
|
|
147
221
|
function fail(message) {
|
|
148
222
|
writeStartupFailureSync(message);
|
|
149
223
|
process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
|
|
@@ -151,18 +225,40 @@ function fail(message) {
|
|
|
151
225
|
}
|
|
152
226
|
// Windows device names: writing to "NUL" or "CON" as a relative path can
|
|
153
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).
|
|
154
231
|
function isReservedFileName(path) {
|
|
155
|
-
|
|
156
|
-
|
|
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));
|
|
157
245
|
}
|
|
158
246
|
// Best-effort lookup of --report-file before the full parse, so a command
|
|
159
247
|
// line that is rejected by the parser still updates that file.
|
|
160
|
-
|
|
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) {
|
|
161
251
|
for (let i = 0; i < argv.length; i++) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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++;
|
|
166
262
|
}
|
|
167
263
|
return null;
|
|
168
264
|
}
|
|
@@ -192,6 +288,8 @@ async function main() {
|
|
|
192
288
|
else if (mode === undefined) {
|
|
193
289
|
// Default: MCP server mode (stdio)
|
|
194
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);
|
|
195
293
|
await startStdio(server);
|
|
196
294
|
}
|
|
197
295
|
else {
|
|
@@ -229,7 +327,7 @@ async function runProxyMode(argv) {
|
|
|
229
327
|
printHelp();
|
|
230
328
|
return;
|
|
231
329
|
}
|
|
232
|
-
const peeked = peekReportFile(argv);
|
|
330
|
+
const peeked = peekReportFile(argv, PROXY_FLAGS);
|
|
233
331
|
if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked)) {
|
|
234
332
|
startupReportFile = peeked;
|
|
235
333
|
}
|
|
@@ -291,12 +389,12 @@ async function runProxyMode(argv) {
|
|
|
291
389
|
}
|
|
292
390
|
retryAfterSec = value;
|
|
293
391
|
}
|
|
294
|
-
const auditToolsRaw = one("--audit-tools");
|
|
392
|
+
const auditToolsRaw = one("--audit-tools")?.toLowerCase();
|
|
295
393
|
if (auditToolsRaw !== undefined && auditToolsRaw !== "on" && auditToolsRaw !== "off") {
|
|
296
394
|
fail(`--audit-tools must be "on" or "off", got "${auditToolsRaw}".`);
|
|
297
395
|
}
|
|
298
396
|
const auditTools = auditToolsRaw !== "off";
|
|
299
|
-
|
|
397
|
+
let reportFile = one("--report-file");
|
|
300
398
|
const writeToolPatterns = splitList(one("--write-tools"), "--write-tools");
|
|
301
399
|
const readToolPatterns = splitList(one("--read-tools"), "--read-tools");
|
|
302
400
|
const keyFields = splitList(one("--key-fields"), "--key-fields");
|
|
@@ -309,17 +407,41 @@ async function runProxyMode(argv) {
|
|
|
309
407
|
}
|
|
310
408
|
if (reportFile !== undefined) {
|
|
311
409
|
if (isReservedFileName(reportFile)) {
|
|
312
|
-
fail(`--report-file "${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.`);
|
|
313
411
|
}
|
|
314
412
|
const info = await stat(reportFile).catch(() => null);
|
|
315
413
|
if (info?.isDirectory()) {
|
|
316
414
|
fail(`--report-file "${reportFile}" is a directory, not a file.`);
|
|
317
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
|
+
}
|
|
318
435
|
}
|
|
436
|
+
const reportTarget = reportFile;
|
|
319
437
|
const log = (line) => {
|
|
320
438
|
process.stderr.write(`[cuonztech-agent-harness] ${line}\n`);
|
|
321
439
|
};
|
|
322
|
-
|
|
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) {
|
|
323
445
|
log("WARNING: --mode chaos with a 0% error rate — this run will inject nothing. " +
|
|
324
446
|
"Pass e.g. --error-rate 0.2 to actually trigger stochastic failures.");
|
|
325
447
|
}
|
|
@@ -328,7 +450,7 @@ async function runProxyMode(argv) {
|
|
|
328
450
|
"reports a timeout to the agent. Only point --upstream-command at a disposable " +
|
|
329
451
|
"test/staging backend, never production.");
|
|
330
452
|
}
|
|
331
|
-
else if (scenarioId || chaosMode === "chaos") {
|
|
453
|
+
else if (scenarioId || (chaosMode === "chaos" && !chaosInjectsNothing)) {
|
|
332
454
|
log("WARNING: failure injection is active — the agent will receive errors for calls " +
|
|
333
455
|
"that were never sent upstream. Every other write is executed for real. Only " +
|
|
334
456
|
"point --upstream-command at a disposable test/staging backend, never production.");
|
|
@@ -352,10 +474,33 @@ async function runProxyMode(argv) {
|
|
|
352
474
|
keyFields,
|
|
353
475
|
retryAfterSec,
|
|
354
476
|
});
|
|
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();
|
|
355
498
|
try {
|
|
356
499
|
await interceptor.connectUpstream();
|
|
357
500
|
}
|
|
358
501
|
catch (err) {
|
|
502
|
+
// A hanging upstream is still running at this point.
|
|
503
|
+
await interceptor.disconnectUpstream().catch(() => undefined);
|
|
359
504
|
const tail = interceptor.getUpstreamStderrTail().trim();
|
|
360
505
|
const reason = `could not start or connect to the upstream MCP server ` +
|
|
361
506
|
`("${upstreamCommand}${upstreamArgs.length > 0 ? " " + upstreamArgs.join(" ") : ""}"): ` +
|
|
@@ -378,23 +523,23 @@ async function runProxyMode(argv) {
|
|
|
378
523
|
const classification = interceptor.getToolClassification();
|
|
379
524
|
log(`Upstream tools (${classification.length}):`);
|
|
380
525
|
for (const entry of classification) {
|
|
381
|
-
log(` ${entry.class.padEnd(12)} ${entry.name} (${entry.source})`);
|
|
526
|
+
log(` ${entry.class.padEnd(12)} ${shown(entry.name)} (${entry.source})`);
|
|
382
527
|
}
|
|
383
528
|
for (const entry of classification.filter((c) => c.class === "unclassified")) {
|
|
384
|
-
log(`WARNING: tool "${entry.name}" could not be classified as read or write. Its calls ` +
|
|
529
|
+
log(`WARNING: tool "${shown(entry.name)}" could not be classified as read or write. Its calls ` +
|
|
385
530
|
"are passed through but never trigger a scenario and are not scored — if it writes, " +
|
|
386
|
-
`duplicates through it stay undetected. Classify it with --write-tools "${entry.name}" ` +
|
|
387
|
-
`or --read-tools "${entry.name}".`);
|
|
531
|
+
`duplicates through it stay undetected. Classify it with --write-tools "${shown(entry.name)}" ` +
|
|
532
|
+
`or --read-tools "${shown(entry.name)}".`);
|
|
388
533
|
}
|
|
389
534
|
if (upstreamTools.length > 0 && !classification.some((c) => c.class === "write")) {
|
|
390
535
|
log(`WARNING: none of the upstream's ${upstreamTools.length} tool(s) ` +
|
|
391
|
-
`(${upstreamTools.map((t) => t.name).join(", ")}) is classified as a write. ` +
|
|
536
|
+
`(${upstreamTools.map((t) => shown(t.name)).join(", ")}) is classified as a write. ` +
|
|
392
537
|
"Scenarios will NEVER trigger for this upstream and every report will read NOT_TESTED. " +
|
|
393
538
|
'If your write tools use different naming, pass e.g. --write-tools "yourprefix_*".');
|
|
394
539
|
}
|
|
395
540
|
const keyCapable = interceptor.getKeyCapableTools();
|
|
396
541
|
log(keyCapable.length > 0
|
|
397
|
-
? `Idempotency-key argument recognized on: ${keyCapable.join(", ")}`
|
|
542
|
+
? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
|
|
398
543
|
: "NOTE: no upstream tool has an argument recognized as an idempotency key " +
|
|
399
544
|
"(looked for: " +
|
|
400
545
|
(keyFields ?? ["idempotency_key"]).join(", ") +
|
|
@@ -417,12 +562,30 @@ async function runProxyMode(argv) {
|
|
|
417
562
|
// not shut down politely, so waiting for exit would lose the result).
|
|
418
563
|
// Writes are chained so they cannot overlap, and go through a temp file
|
|
419
564
|
// so a reader never sees a half-written report.
|
|
420
|
-
|
|
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;
|
|
421
570
|
let reportWriteFailed = false;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 {
|
|
426
589
|
const report = generateReport(interceptor.getSession());
|
|
427
590
|
const score = computeScore([report]);
|
|
428
591
|
const patches = generatePatches([report], score);
|
|
@@ -438,30 +601,68 @@ async function runProxyMode(argv) {
|
|
|
438
601
|
toolClassification: classification,
|
|
439
602
|
generatedAt: new Date().toISOString(),
|
|
440
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`;
|
|
441
609
|
try {
|
|
442
|
-
await
|
|
443
|
-
|
|
444
|
-
await writeFile(tmp, body, "utf-8");
|
|
445
|
-
await rename(tmp, reportFile);
|
|
610
|
+
await writeFile(tmp, body, { encoding: "utf-8", flag: "wx" });
|
|
611
|
+
await renameWithRetry(tmp, reportTarget);
|
|
446
612
|
}
|
|
447
|
-
catch
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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");
|
|
451
623
|
}
|
|
624
|
+
await writeFile(reportTarget, body, "utf-8");
|
|
452
625
|
}
|
|
453
|
-
|
|
454
|
-
|
|
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;
|
|
455
657
|
};
|
|
456
658
|
if (reportFile) {
|
|
457
|
-
interceptor.onCallRecorded = () =>
|
|
458
|
-
void writeReportFile();
|
|
459
|
-
};
|
|
659
|
+
interceptor.onCallRecorded = () => writeReportFile();
|
|
460
660
|
await writeReportFile();
|
|
461
661
|
if (reportWriteFailed) {
|
|
462
662
|
await interceptor.disconnectUpstream();
|
|
463
663
|
fail(`cannot write --report-file "${reportFile}".`);
|
|
464
664
|
}
|
|
665
|
+
stopOnWriteFailure = true;
|
|
465
666
|
}
|
|
466
667
|
interceptor.onUpstreamLost = (stderrTail) => {
|
|
467
668
|
log("ERROR: the upstream MCP server process exited or closed its connection. Every " +
|
|
@@ -478,16 +679,31 @@ async function runProxyMode(argv) {
|
|
|
478
679
|
shuttingDown = true;
|
|
479
680
|
await writeReportFile();
|
|
480
681
|
await interceptor.disconnectUpstream();
|
|
481
|
-
|
|
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);
|
|
482
690
|
};
|
|
483
|
-
|
|
484
|
-
process.
|
|
691
|
+
onSignal = shutdown;
|
|
692
|
+
process.stdin.off("readable", onEarlyReadable);
|
|
693
|
+
process.stdin.off("end", onEarlyEnd);
|
|
485
694
|
// The client closing our stdin is the normal way an MCP stdio session
|
|
486
695
|
// ends; without this the proxy (and its upstream child) would linger.
|
|
487
696
|
process.stdin.on("end", shutdown);
|
|
488
697
|
process.stdin.on("close", shutdown);
|
|
489
698
|
startupReportFile = null;
|
|
490
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();
|
|
706
|
+
};
|
|
491
707
|
await startStdio(server);
|
|
492
708
|
}
|
|
493
709
|
const BENCHMARK_FLAGS = {
|
|
@@ -588,10 +804,9 @@ async function runBenchmarkMode(argv) {
|
|
|
588
804
|
try {
|
|
589
805
|
await mkdir(reportDir, { recursive: true });
|
|
590
806
|
await writeFile(jsonPath, JSON.stringify({
|
|
591
|
-
//
|
|
592
|
-
//
|
|
807
|
+
// Constant, as in the proxy's report file. PASS/FAIL per reference
|
|
808
|
+
// agent is in careful.score.verdict / naive.score.verdict.
|
|
593
809
|
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
594
|
-
verdict: "UNVERIFIED_FOR_PRODUCTION",
|
|
595
810
|
isReferenceRun: true,
|
|
596
811
|
note: "Self-test of the harness with two built-in reference agents. Neither score " +
|
|
597
812
|
"says anything about your own agent.",
|
package/dist/proxy/gateway.js
CHANGED
|
@@ -72,7 +72,8 @@ export function createProxyServer(interceptor, options = {}) {
|
|
|
72
72
|
const { name, arguments: rawArgs } = request.params;
|
|
73
73
|
const toolArgs = (rawArgs ?? {});
|
|
74
74
|
if (auditTools && isOwnTool(name)) {
|
|
75
|
-
const
|
|
75
|
+
const rawFormat = toolArgs["format"] ?? "markdown";
|
|
76
|
+
const format = typeof rawFormat === "string" ? rawFormat.toLowerCase() : rawFormat;
|
|
76
77
|
if (format !== "markdown" && format !== "json") {
|
|
77
78
|
return {
|
|
78
79
|
content: [
|