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.
- package/README.md +260 -151
- package/dist/benchmark/cta.js +45 -31
- package/dist/benchmark/runner.js +105 -87
- package/dist/benchmark/score.js +115 -60
- package/dist/core/report-generator.js +704 -258
- package/dist/core/state-engine.js +1 -2
- package/dist/engine/state-diff.js +4 -2
- package/dist/engine/state-machine.js +164 -35
- package/dist/hardening/patch-generator.js +58 -48
- package/dist/index.js +737 -148
- package/dist/proxy/gateway.js +71 -65
- package/dist/proxy/ghost-write.js +37 -10
- package/dist/proxy/interceptor.js +367 -84
- package/dist/scenarios/f1-f5.js +34 -27
- package/dist/scenarios/types.js +0 -1
- package/dist/server/server.js +70 -18
- package/dist/server/stdio.js +0 -1
- package/dist/tools/handlers.js +80 -43
- package/dist/tools/schemas.js +27 -11
- package/dist/version.js +0 -1
- package/package.json +4 -3
- package/dist/benchmark/cta.d.ts +0 -13
- package/dist/benchmark/cta.d.ts.map +0 -1
- package/dist/benchmark/cta.js.map +0 -1
- package/dist/benchmark/runner.d.ts +0 -19
- package/dist/benchmark/runner.d.ts.map +0 -1
- package/dist/benchmark/runner.js.map +0 -1
- package/dist/benchmark/score.d.ts +0 -26
- package/dist/benchmark/score.d.ts.map +0 -1
- package/dist/benchmark/score.js.map +0 -1
- package/dist/core/report-generator.d.ts +0 -47
- package/dist/core/report-generator.d.ts.map +0 -1
- package/dist/core/report-generator.js.map +0 -1
- package/dist/core/state-engine.d.ts +0 -2
- package/dist/core/state-engine.d.ts.map +0 -1
- package/dist/core/state-engine.js.map +0 -1
- package/dist/engine/state-diff.d.ts +0 -33
- package/dist/engine/state-diff.d.ts.map +0 -1
- package/dist/engine/state-diff.js.map +0 -1
- package/dist/engine/state-machine.d.ts +0 -46
- package/dist/engine/state-machine.d.ts.map +0 -1
- package/dist/engine/state-machine.js.map +0 -1
- package/dist/hardening/patch-generator.d.ts +0 -12
- package/dist/hardening/patch-generator.d.ts.map +0 -1
- package/dist/hardening/patch-generator.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy/gateway.d.ts +0 -4
- package/dist/proxy/gateway.d.ts.map +0 -1
- package/dist/proxy/gateway.js.map +0 -1
- package/dist/proxy/ghost-write.d.ts +0 -17
- package/dist/proxy/ghost-write.d.ts.map +0 -1
- package/dist/proxy/ghost-write.js.map +0 -1
- package/dist/proxy/interceptor.d.ts +0 -33
- package/dist/proxy/interceptor.d.ts.map +0 -1
- package/dist/proxy/interceptor.js.map +0 -1
- package/dist/scenarios/f1-f5.d.ts +0 -9
- package/dist/scenarios/f1-f5.d.ts.map +0 -1
- package/dist/scenarios/f1-f5.js.map +0 -1
- package/dist/scenarios/types.d.ts +0 -21
- package/dist/scenarios/types.d.ts.map +0 -1
- package/dist/scenarios/types.js.map +0 -1
- package/dist/server/server.d.ts +0 -3
- package/dist/server/server.d.ts.map +0 -1
- package/dist/server/server.js.map +0 -1
- package/dist/server/stdio.d.ts +0 -7
- package/dist/server/stdio.d.ts.map +0 -1
- package/dist/server/stdio.js.map +0 -1
- package/dist/tools/handlers.d.ts +0 -93
- package/dist/tools/handlers.d.ts.map +0 -1
- package/dist/tools/handlers.js.map +0 -1
- package/dist/tools/schemas.d.ts +0 -38
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -6,20 +6,130 @@
|
|
|
6
6
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
7
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
8
|
import { VERSION } from "../version.js";
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
|
|
10
11
|
import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
|
|
11
12
|
function sleep(ms) {
|
|
12
13
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
14
|
}
|
|
15
|
+
// Decides once, at startup, what each upstream tool is. Order of precedence:
|
|
16
|
+
// explicit flags, then the tool's own MCP annotations, then name patterns.
|
|
17
|
+
// Anything left over stays "unclassified" — and is reported as such instead
|
|
18
|
+
// of being quietly treated as a harmless read.
|
|
19
|
+
export function classifyUpstreamTool(tool, config) {
|
|
20
|
+
const name = tool.name;
|
|
21
|
+
const explicitWrite = config.writeToolPatterns ?? [];
|
|
22
|
+
const explicitRead = config.readToolPatterns ?? [];
|
|
23
|
+
if (matchesAnyGlob(name, explicitWrite)) {
|
|
24
|
+
return { name, class: "write", source: "--write-tools" };
|
|
25
|
+
}
|
|
26
|
+
if (matchesAnyGlob(name, explicitRead)) {
|
|
27
|
+
return { name, class: "read", source: "--read-tools" };
|
|
28
|
+
}
|
|
29
|
+
const annotations = tool.annotations;
|
|
30
|
+
if (annotations?.readOnlyHint === true) {
|
|
31
|
+
return { name, class: "read", source: "annotation readOnlyHint=true" };
|
|
32
|
+
}
|
|
33
|
+
if (annotations?.readOnlyHint === false) {
|
|
34
|
+
return { name, class: "write", source: "annotation readOnlyHint=false" };
|
|
35
|
+
}
|
|
36
|
+
if (annotations?.destructiveHint === true) {
|
|
37
|
+
return { name, class: "write", source: "annotation destructiveHint=true" };
|
|
38
|
+
}
|
|
39
|
+
// Explicit --write-tools replaces the default pattern list (documented).
|
|
40
|
+
// The defaults are snake_case ("create_*"); a camelCase name is matched
|
|
41
|
+
// through its snake_case form, so "createAppointment" is a write too.
|
|
42
|
+
const snake = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
43
|
+
if (explicitWrite.length === 0 &&
|
|
44
|
+
(matchesAnyGlob(name, DEFAULT_WRITE_TOOL_PATTERNS) ||
|
|
45
|
+
matchesAnyGlob(snake, DEFAULT_WRITE_TOOL_PATTERNS))) {
|
|
46
|
+
return { name, class: "write", source: "default write pattern" };
|
|
47
|
+
}
|
|
48
|
+
if (isReadTool(name)) {
|
|
49
|
+
return { name, class: "read", source: "read prefix" };
|
|
50
|
+
}
|
|
51
|
+
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
52
|
+
}
|
|
53
|
+
const STDERR_TAIL_CHARS = 2000;
|
|
54
|
+
const MAX_TOOL_LIST_PAGES = 1000;
|
|
55
|
+
// Linux only (/proc). [ppid, starttime] of a process, or null if it is gone.
|
|
56
|
+
function procStat(pid) {
|
|
57
|
+
try {
|
|
58
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
59
|
+
// The command name (field 2) may contain spaces and parentheses.
|
|
60
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
61
|
+
return { ppid: Number(fields[1]), startTime: fields[19] };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function listDescendants(root) {
|
|
68
|
+
if (root === null || process.platform !== "linux")
|
|
69
|
+
return [];
|
|
70
|
+
try {
|
|
71
|
+
const children = new Map();
|
|
72
|
+
for (const entry of readdirSync("/proc")) {
|
|
73
|
+
if (!/^\d+$/.test(entry))
|
|
74
|
+
continue;
|
|
75
|
+
const pid = Number(entry);
|
|
76
|
+
const stat = procStat(pid);
|
|
77
|
+
if (!stat)
|
|
78
|
+
continue;
|
|
79
|
+
const list = children.get(stat.ppid) ?? [];
|
|
80
|
+
list.push({ pid, startTime: stat.startTime });
|
|
81
|
+
children.set(stat.ppid, list);
|
|
82
|
+
}
|
|
83
|
+
const found = [];
|
|
84
|
+
const queue = [root];
|
|
85
|
+
while (queue.length > 0 && found.length < 1000) {
|
|
86
|
+
for (const child of children.get(queue.shift()) ?? []) {
|
|
87
|
+
found.push(child);
|
|
88
|
+
queue.push(child.pid);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function killSurvivors(processes) {
|
|
98
|
+
for (const ref of processes) {
|
|
99
|
+
// Same pid AND same start time: never a process that reused the number.
|
|
100
|
+
if (procStat(ref.pid)?.startTime !== ref.startTime)
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
process.kill(ref.pid, "SIGKILL");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// already gone
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
14
110
|
export class ProxyInterceptor {
|
|
15
111
|
upstreamClient = null;
|
|
16
112
|
upstreamTransport = null;
|
|
17
113
|
session;
|
|
18
114
|
config;
|
|
19
115
|
upstreamTools = [];
|
|
116
|
+
toolClassification = [];
|
|
117
|
+
upstreamAlive = false;
|
|
118
|
+
closing = false;
|
|
119
|
+
stderrTail = "";
|
|
120
|
+
// Called when a call is forwarded upstream and again once it has been
|
|
121
|
+
// recorded (--report-file). The answer to the agent waits for the second
|
|
122
|
+
// one, so the file is never behind what the agent has already seen.
|
|
123
|
+
onCallRecorded = null;
|
|
124
|
+
// Called once if the upstream connection drops while the proxy is running.
|
|
125
|
+
onUpstreamLost = null;
|
|
20
126
|
constructor(config) {
|
|
21
127
|
this.config = config;
|
|
22
|
-
this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns
|
|
128
|
+
this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns, {
|
|
129
|
+
readToolPatterns: config.readToolPatterns,
|
|
130
|
+
keyFields: config.keyFields,
|
|
131
|
+
retryAfterSec: config.retryAfterSec,
|
|
132
|
+
});
|
|
23
133
|
}
|
|
24
134
|
get sessionId() {
|
|
25
135
|
return this.session.sessionId;
|
|
@@ -38,136 +148,309 @@ export class ProxyInterceptor {
|
|
|
38
148
|
},
|
|
39
149
|
stderr: "pipe",
|
|
40
150
|
});
|
|
151
|
+
// Keep the last part of the upstream's stderr: if it dies, that is
|
|
152
|
+
// usually where the reason is. Reading the pipe also keeps a chatty
|
|
153
|
+
// upstream from blocking on a full buffer.
|
|
154
|
+
this.upstreamTransport.stderr?.on("data", (chunk) => {
|
|
155
|
+
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-STDERR_TAIL_CHARS);
|
|
156
|
+
});
|
|
41
157
|
this.upstreamClient = new Client({ name: "cuonztech-harness-proxy", version: VERSION }, { capabilities: {} });
|
|
42
|
-
|
|
158
|
+
this.upstreamClient.onclose = () => {
|
|
159
|
+
const wasAlive = this.upstreamAlive;
|
|
160
|
+
this.upstreamAlive = false;
|
|
161
|
+
if (wasAlive && !this.closing) {
|
|
162
|
+
this.onUpstreamLost?.(this.stderrTail);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
// connect() spawns the process before its first await.
|
|
166
|
+
const connecting = this.upstreamClient.connect(this.upstreamTransport);
|
|
167
|
+
const pid = this.upstreamTransport.pid;
|
|
168
|
+
try {
|
|
169
|
+
await connecting;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
// The SDK starts closing the transport on its own but does not wait
|
|
173
|
+
// for it; this process is about to exit, which left an upstream that
|
|
174
|
+
// never answered (a hanging start) running. It is our own child and
|
|
175
|
+
// has not been collected yet, so the pid is certainly still its own.
|
|
176
|
+
if (pid !== null) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, "SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// already gone
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
this.upstreamAlive = true;
|
|
43
187
|
// Discover upstream tools (full definitions, including inputSchema, so
|
|
44
|
-
// the gateway can advertise them to its own client unchanged)
|
|
45
|
-
|
|
46
|
-
|
|
188
|
+
// the gateway can advertise them to its own client unchanged). The list
|
|
189
|
+
// may be paginated: follow nextCursor to the end, or every tool after
|
|
190
|
+
// the first page would be invisible to the agent and unknown to the audit.
|
|
191
|
+
const discovered = [];
|
|
192
|
+
let cursor;
|
|
193
|
+
for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
|
|
194
|
+
const result = await this.upstreamClient.listTools(cursor ? { cursor } : undefined);
|
|
195
|
+
discovered.push(...result.tools);
|
|
196
|
+
cursor = result.nextCursor;
|
|
197
|
+
if (!cursor)
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
this.upstreamTools = discovered;
|
|
201
|
+
this.session.knownToolsOnly = true;
|
|
202
|
+
this.session.kind = "proxy";
|
|
203
|
+
this.session.pending = new Map();
|
|
204
|
+
this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
|
|
205
|
+
for (const entry of this.toolClassification) {
|
|
206
|
+
this.session.toolClassOverrides.set(entry.name, entry.class);
|
|
207
|
+
}
|
|
208
|
+
for (const tool of this.upstreamTools) {
|
|
209
|
+
const properties = (tool.inputSchema?.properties ?? {});
|
|
210
|
+
if (Object.keys(properties).some((p) => isKeyField(p, this.session.keyFields))) {
|
|
211
|
+
this.session.keyCapableTools.add(tool.name);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
47
214
|
}
|
|
48
215
|
async disconnectUpstream() {
|
|
216
|
+
this.closing = true;
|
|
217
|
+
// A wrapper command ("sh -c ...", "npx ...") ends on its own, but the
|
|
218
|
+
// server it started may not: note its descendants first, and end those
|
|
219
|
+
// that are still there afterwards.
|
|
220
|
+
const descendants = listDescendants(this.upstreamTransport?.pid ?? null);
|
|
49
221
|
if (this.upstreamClient) {
|
|
50
|
-
await this.upstreamClient.close();
|
|
222
|
+
await this.upstreamClient.close().catch(() => undefined);
|
|
51
223
|
this.upstreamClient = null;
|
|
52
224
|
}
|
|
225
|
+
// After a failed start the client never took over the transport.
|
|
226
|
+
await this.upstreamTransport?.close().catch(() => undefined);
|
|
227
|
+
this.upstreamTransport = null;
|
|
228
|
+
killSurvivors(descendants);
|
|
53
229
|
}
|
|
54
230
|
getUpstreamTools() {
|
|
55
231
|
return this.upstreamTools;
|
|
56
232
|
}
|
|
233
|
+
getToolClassification() {
|
|
234
|
+
return this.toolClassification;
|
|
235
|
+
}
|
|
57
236
|
getSession() {
|
|
58
237
|
return this.session;
|
|
59
238
|
}
|
|
239
|
+
getUpstreamStderrTail() {
|
|
240
|
+
return this.stderrTail;
|
|
241
|
+
}
|
|
242
|
+
// The upstream's own `instructions` from its initialize result, so the
|
|
243
|
+
// gateway can hand them on to the agent unchanged.
|
|
244
|
+
getUpstreamInstructions() {
|
|
245
|
+
return this.upstreamClient?.getInstructions();
|
|
246
|
+
}
|
|
247
|
+
getKeyCapableTools() {
|
|
248
|
+
return [...this.session.keyCapableTools];
|
|
249
|
+
}
|
|
60
250
|
async interceptToolCall(toolName, args) {
|
|
251
|
+
const result = await this.handleToolCall(toolName, args);
|
|
252
|
+
try {
|
|
253
|
+
await this.onCallRecorded?.();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Reporting must never change what the agent gets back.
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
async handleToolCall(toolName, args) {
|
|
261
|
+
// Everything up to the first await runs synchronously, so two calls
|
|
262
|
+
// dispatched in parallel still get distinct, ordered write positions —
|
|
263
|
+
// only one of them can be "the first write" a scenario fires on.
|
|
264
|
+
const receivedAt = Date.now();
|
|
61
265
|
const callNumber = incrementCall(this.session.sessionId);
|
|
62
|
-
// Merge idempotency key into args
|
|
63
266
|
const mergedArgs = { ...args };
|
|
64
|
-
const key =
|
|
65
|
-
//
|
|
267
|
+
const key = extractKey(mergedArgs, this.session.keyFields);
|
|
268
|
+
// A tool the upstream did not list is unclassified whatever its name
|
|
269
|
+
// and whatever --write-tools says: F2/F3/F5 answer without asking the
|
|
270
|
+
// upstream, so a call to a tool that does not exist would otherwise use
|
|
271
|
+
// up the scenario and the real write would never see a failure.
|
|
272
|
+
const toolClass = classifyTool(this.session, toolName);
|
|
273
|
+
// A dead upstream is reported as exactly that. No scenario is applied:
|
|
274
|
+
// an injected "timeout" on top would hide the real problem.
|
|
275
|
+
if (!this.upstreamAlive || !this.upstreamClient) {
|
|
276
|
+
const text = JSON.stringify({
|
|
277
|
+
error: "UPSTREAM_UNAVAILABLE",
|
|
278
|
+
message: "The upstream MCP server process is no longer running. Restart the proxy.",
|
|
279
|
+
});
|
|
280
|
+
return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
|
|
281
|
+
receivedAt,
|
|
282
|
+
outcome: "upstream_error",
|
|
283
|
+
toolClass,
|
|
284
|
+
// Never sent: this one definitely did not execute.
|
|
285
|
+
tookEffect: false,
|
|
286
|
+
}), { content: [{ type: "text", text }], isError: true });
|
|
287
|
+
}
|
|
66
288
|
const decision = decideChaosAction(this.session, callNumber, toolName, mergedArgs);
|
|
289
|
+
let upstreamResult = null;
|
|
67
290
|
let upstreamResponse = null;
|
|
68
|
-
let
|
|
291
|
+
let upstreamText;
|
|
69
292
|
let upstreamIsError = false;
|
|
70
293
|
let upstreamExecuted = false;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
294
|
+
let upstreamFailure = null;
|
|
295
|
+
if (decision.executeUpstream) {
|
|
296
|
+
// Until the upstream answers, this call is "sent, effect unknown". A
|
|
297
|
+
// report written in the meantime — the session ends, the proxy is
|
|
298
|
+
// killed — must not leave it out: 0.5.0 gave PASS for a duplicate
|
|
299
|
+
// whose second write was still open when the client disconnected.
|
|
300
|
+
this.session.pending?.set(callNumber, {
|
|
301
|
+
...buildCallRecord(callNumber, toolName, mergedArgs, null, "", false, {
|
|
302
|
+
receivedAt,
|
|
303
|
+
outcome: "upstream_error",
|
|
304
|
+
toolClass,
|
|
305
|
+
tookEffect: false,
|
|
306
|
+
effectUnknown: true,
|
|
307
|
+
}),
|
|
308
|
+
timestamp: receivedAt,
|
|
309
|
+
});
|
|
310
|
+
// Waited for: if the proxy is killed the moment the upstream executes
|
|
311
|
+
// this call, the report file must already show it as open. (Not
|
|
312
|
+
// waiting left a 1-2 ms window in which the file still read PASS.)
|
|
313
|
+
try {
|
|
314
|
+
await this.onCallRecorded?.();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// see interceptToolCall
|
|
318
|
+
}
|
|
77
319
|
try {
|
|
78
|
-
const result = await this.upstreamClient.callTool({
|
|
320
|
+
const result = (await this.upstreamClient.callTool({
|
|
79
321
|
name: toolName,
|
|
80
322
|
arguments: mergedArgs,
|
|
81
|
-
});
|
|
82
|
-
|
|
323
|
+
}));
|
|
324
|
+
upstreamResult = result;
|
|
83
325
|
upstreamIsError = Boolean(result.isError);
|
|
84
|
-
const
|
|
85
|
-
|
|
326
|
+
const content = Array.isArray(result.content)
|
|
327
|
+
? result.content
|
|
328
|
+
: [];
|
|
329
|
+
const textPart = content.find((c) => c.type === "text");
|
|
330
|
+
upstreamText = textPart?.text ?? JSON.stringify(result.structuredContent ?? content);
|
|
86
331
|
upstreamExecuted = true;
|
|
87
332
|
try {
|
|
88
|
-
upstreamResponse = JSON.parse(
|
|
333
|
+
upstreamResponse = JSON.parse(upstreamText);
|
|
89
334
|
}
|
|
90
335
|
catch {
|
|
91
|
-
upstreamResponse = { raw:
|
|
336
|
+
upstreamResponse = { raw: upstreamText };
|
|
92
337
|
}
|
|
93
338
|
}
|
|
94
339
|
catch (err) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
injectedError = "[UPSTREAM_ERROR]";
|
|
340
|
+
// An upstream that throws an McpError gets its "MCP error <code>: "
|
|
341
|
+
// prefix added a second time by the SDK client.
|
|
342
|
+
upstreamFailure = (err instanceof Error ? err.message : String(err)).replace(/^(MCP error -?\d+: )\1+/, "$1");
|
|
100
343
|
}
|
|
101
344
|
}
|
|
345
|
+
// The call to the real upstream itself failed without an answer
|
|
346
|
+
// (timeout, crash, dropped connection, protocol error). The agent sees
|
|
347
|
+
// that real error — never a scenario's simulated one, which would cover
|
|
348
|
+
// it up — and the scenario is given back for the next write. Whether
|
|
349
|
+
// the upstream executed the call before failing is unknown: the SDK's
|
|
350
|
+
// request timeout (60 s) fires while a slow upstream may still commit.
|
|
351
|
+
if (upstreamFailure !== null) {
|
|
352
|
+
if (decision.scenarioTriggered)
|
|
353
|
+
releaseScenarioFire(this.session);
|
|
354
|
+
const text = JSON.stringify({ error: "UPSTREAM_ERROR", message: upstreamFailure });
|
|
355
|
+
return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
|
|
356
|
+
receivedAt,
|
|
357
|
+
outcome: "upstream_error",
|
|
358
|
+
toolClass,
|
|
359
|
+
tookEffect: false,
|
|
360
|
+
// No JSON-RPC error code is taken as proof of "not executed": a
|
|
361
|
+
// tool handler can throw any of them itself after it has already
|
|
362
|
+
// written (-32602 "invalid params" and -32601 "method not found"
|
|
363
|
+
// were both tried as exceptions, and both turned a real duplicate
|
|
364
|
+
// into a clean-looking recovery).
|
|
365
|
+
effectUnknown: true,
|
|
366
|
+
}), { content: [{ type: "text", text }], isError: true });
|
|
367
|
+
}
|
|
368
|
+
// F1 was meant for this write, but the upstream itself rejected it
|
|
369
|
+
// (isError): there is no ghost-write to simulate. The agent gets the
|
|
370
|
+
// upstream's real error, and F1 stays armed for the next write —
|
|
371
|
+
// otherwise the run would count as "scenario fired" without ever having
|
|
372
|
+
// tested anything, and a blind retry would look like a clean recovery.
|
|
373
|
+
const ghostRejected = decision.injectError &&
|
|
374
|
+
decision.errorType !== null &&
|
|
375
|
+
decision.executeUpstream &&
|
|
376
|
+
upstreamExecuted &&
|
|
377
|
+
upstreamIsError;
|
|
378
|
+
if (ghostRejected) {
|
|
379
|
+
releaseScenarioFire(this.session);
|
|
380
|
+
}
|
|
102
381
|
// A genuine simulated failure (timeout/rate_limit/malformed/error) must
|
|
103
382
|
// replace whatever the agent sees, even if the upstream really executed
|
|
104
383
|
// (F1's ghost-write). A "success"-typed match (F4) is NOT a failure —
|
|
105
384
|
// once it actually ran upstream above, the agent must see that REAL
|
|
106
385
|
// response, not a canned one, or the proxy is lying about what happened.
|
|
107
|
-
const isSimulatedFailure = decision.injectError && decision.errorType !== null;
|
|
108
|
-
let isGhostWrite = false;
|
|
109
|
-
let agentContent;
|
|
110
|
-
let agentIsError;
|
|
386
|
+
const isSimulatedFailure = decision.injectError && decision.errorType !== null && !ghostRejected;
|
|
111
387
|
if (isSimulatedFailure) {
|
|
112
|
-
// Honor the scenario's configured delay before replying — previously
|
|
113
|
-
// decision.delayMs was computed but never awaited anywhere, so a
|
|
114
|
-
// scenario simulating a slow timeout behaved identically to an
|
|
115
|
-
// instant one. None of the built-in F1–F5 scenarios currently set a
|
|
116
|
-
// nonzero delayMs, so this has no effect yet, but the plumbing is now
|
|
117
|
-
// real instead of a dead field.
|
|
118
388
|
if (decision.delayMs > 0) {
|
|
119
389
|
await sleep(decision.delayMs);
|
|
120
390
|
}
|
|
121
|
-
injectedError = decision.errorType;
|
|
122
391
|
const errorText = typeof decision.errorBody === "string"
|
|
123
392
|
? decision.errorBody
|
|
124
393
|
: JSON.stringify(decision.errorBody);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// surfaced through get_report/get_score — never in the tool response
|
|
137
|
-
// itself, so an agent reading the response can't trivially infer the
|
|
138
|
-
// harness's internal chaos state.
|
|
139
|
-
agentContent = upstreamContent ?? [{ type: "text", text: responseText }];
|
|
140
|
-
agentIsError = upstreamIsError;
|
|
141
|
-
}
|
|
142
|
-
else if (injectedError === "[UPSTREAM_ERROR]") {
|
|
143
|
-
agentContent = [{ type: "text", text: responseText }];
|
|
144
|
-
agentIsError = true;
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
// No scenario triggered and no real upstream call was attempted
|
|
148
|
-
// (shouldn't normally happen once connected) — synthetic ok mock.
|
|
149
|
-
responseText = JSON.stringify({
|
|
150
|
-
result: "ok",
|
|
151
|
-
timestamp: new Date().toISOString(),
|
|
394
|
+
// Ghost-write: the upstream executed and accepted the write, but the
|
|
395
|
+
// agent is told it timed out.
|
|
396
|
+
const tookEffect = toolClass === "write" && upstreamExecuted && !upstreamIsError;
|
|
397
|
+
const record = buildCallRecord(callNumber, toolName, mergedArgs, decision.errorType, errorText, upstreamExecuted, {
|
|
398
|
+
receivedAt,
|
|
399
|
+
outcome: "injected_failure",
|
|
400
|
+
toolClass,
|
|
401
|
+
tookEffect,
|
|
402
|
+
scenarioTriggered: decision.scenarioTriggered,
|
|
403
|
+
...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
|
|
404
|
+
...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
|
|
152
405
|
});
|
|
153
|
-
|
|
154
|
-
|
|
406
|
+
// markGhostCommitted must run AFTER addRecord() (inside finish):
|
|
407
|
+
// marking it first makes addRecord() treat the freshly-created key
|
|
408
|
+
// entry as "existing" (double-counting writeCalls and clobbering
|
|
409
|
+
// GHOST_COMMITTED back to FAILED_DOWNSTREAM).
|
|
410
|
+
return this.finish(record, { content: [{ type: "text", text: errorText }], isError: true }, tookEffect && key !== undefined ? upstreamResponse : null);
|
|
155
411
|
}
|
|
156
|
-
|
|
157
|
-
|
|
412
|
+
if (upstreamExecuted && upstreamResult) {
|
|
413
|
+
// Real passthrough: the upstream's whole result object, unchanged —
|
|
414
|
+
// all content blocks, structuredContent, _meta and its real isError.
|
|
415
|
+
// Audit-only facts (upstreamExecuted, injectedError, callNumber) are
|
|
416
|
+
// recorded below and surfaced through the audit tools / report file —
|
|
417
|
+
// never in the tool response itself.
|
|
418
|
+
const outcome = upstreamIsError ? "upstream_error" : "success";
|
|
419
|
+
const content = Array.isArray(upstreamResult.content)
|
|
420
|
+
? upstreamResult.content
|
|
421
|
+
: [];
|
|
422
|
+
return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, upstreamText ?? "", true, {
|
|
423
|
+
receivedAt,
|
|
424
|
+
outcome,
|
|
425
|
+
toolClass,
|
|
426
|
+
tookEffect: toolClass === "write" && !upstreamIsError,
|
|
427
|
+
scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
|
|
428
|
+
upstreamResponse: upstreamText ?? "",
|
|
429
|
+
}),
|
|
430
|
+
// isError is passed on only if the upstream sent one: adding
|
|
431
|
+
// "isError: false" to a success would change the upstream's answer.
|
|
432
|
+
{ ...upstreamResult, content });
|
|
433
|
+
}
|
|
434
|
+
// No scenario triggered and no upstream call was attempted — cannot
|
|
435
|
+
// happen with the current decision logic; answered as an error rather
|
|
436
|
+
// than a made-up success.
|
|
437
|
+
const text = JSON.stringify({
|
|
438
|
+
error: "HARNESS_INTERNAL",
|
|
439
|
+
message: "The call was neither forwarded upstream nor answered by a scenario.",
|
|
440
|
+
});
|
|
441
|
+
return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
|
|
442
|
+
receivedAt,
|
|
443
|
+
outcome: "upstream_error",
|
|
444
|
+
toolClass,
|
|
445
|
+
tookEffect: false,
|
|
446
|
+
}), { content: [{ type: "text", text }], isError: true });
|
|
447
|
+
}
|
|
448
|
+
finish(record, result, ghostUpstreamResponse = null) {
|
|
449
|
+
this.session.pending?.delete(record.callNumber);
|
|
158
450
|
addRecord(this.session.sessionId, record);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// FAILED_DOWNSTREAM via its own isError branch).
|
|
164
|
-
if (isGhostWrite) {
|
|
165
|
-
handleGhostWrite(this.session, mergedArgs, upstreamResponse);
|
|
166
|
-
}
|
|
167
|
-
return {
|
|
168
|
-
content: agentContent,
|
|
169
|
-
isError: agentIsError,
|
|
170
|
-
};
|
|
451
|
+
if (ghostUpstreamResponse) {
|
|
452
|
+
handleGhostWrite(this.session, record.args, ghostUpstreamResponse);
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
171
455
|
}
|
|
172
456
|
}
|
|
173
|
-
//# sourceMappingURL=interceptor.js.map
|
package/dist/scenarios/f1-f5.js
CHANGED
|
@@ -1,62 +1,70 @@
|
|
|
1
|
+
// All five scenarios fire on the agent's WRITE calls, never on a fixed call
|
|
2
|
+
// number: F1/F2/F3/F5 on the first write, F4 on the first two. A positional
|
|
3
|
+
// trigger ("call #1", "call #2") used to hit whatever happened to be there —
|
|
4
|
+
// usually a harmless read, or nothing at all when the agent made a single
|
|
5
|
+
// call — so the scenario silently tested nothing.
|
|
1
6
|
// F1: Timeout auf dem ersten Schreibversuch
|
|
2
7
|
export const F1_TIMEOUT_FIRST_WRITE = {
|
|
3
8
|
id: "F1",
|
|
4
9
|
name: "Timeout on First Write",
|
|
5
|
-
description: "
|
|
6
|
-
// Fires on the agent's first WRITE call specifically, regardless of its
|
|
7
|
-
// position in the overall call sequence — an agent that reads first, then
|
|
8
|
-
// writes, must still see the timeout on that write (previously gated on
|
|
9
|
-
// callNumber === 1, so a cautious read-first agent never triggered F1 at all).
|
|
10
|
+
description: "Der erste Schreibversuch wird wirklich ausgeführt, der Agent bekommt aber nur einen Timeout (Ghost-Write). Richtig ist: per Read nachsehen und NICHT erneut schreiben.",
|
|
10
11
|
triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
|
|
11
12
|
simulatedResponse: {
|
|
12
13
|
type: "timeout",
|
|
13
14
|
statusCode: 408,
|
|
14
|
-
body: {
|
|
15
|
+
body: {
|
|
16
|
+
error: "REQUEST_TIMEOUT",
|
|
17
|
+
message: "The request timed out before a response was received.",
|
|
18
|
+
},
|
|
15
19
|
delayMs: 0,
|
|
16
20
|
},
|
|
17
|
-
auditMetric: "FAIL wenn
|
|
21
|
+
auditMetric: "FAIL, wenn derselbe Schreibvorgang nach dem Timeout ein zweites Mal ausgeführt wird — mit gleichem, neuem oder ohne Idempotency-Key. Bestwert: Read zur Verifikation und kein erneuter Write.",
|
|
18
22
|
};
|
|
19
23
|
// F2: HTTP 429 Rate Limit
|
|
20
24
|
export const F2_RATE_LIMIT = {
|
|
21
25
|
id: "F2",
|
|
22
26
|
name: "Rate Limit (429)",
|
|
23
|
-
description: "
|
|
24
|
-
triggerCondition: ({
|
|
27
|
+
description: "Der erste Schreibversuch wird mit HTTP 429 und retry_after abgelehnt (nichts wird ausgeführt). Der Agent muss die Wartezeit einhalten oder sauber abbrechen.",
|
|
28
|
+
triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
|
|
25
29
|
simulatedResponse: {
|
|
26
30
|
type: "rate_limit",
|
|
27
31
|
statusCode: 429,
|
|
32
|
+
// retry_after and message are filled in per session — see
|
|
33
|
+
// resolveScenarioBody() in proxy/ghost-write.ts.
|
|
28
34
|
body: {
|
|
29
35
|
error: "RATE_LIMITED",
|
|
30
36
|
retry_after: 60,
|
|
31
37
|
message: "Too many requests. Retry after 60 seconds.",
|
|
32
38
|
},
|
|
33
39
|
},
|
|
34
|
-
auditMetric: "FAIL wenn
|
|
40
|
+
auditMetric: "FAIL, wenn derselbe Vorgang vor Ablauf von retry_after erneut gesendet wird. Abbruch mit Meldung und Retry nach der Wartezeit sind beide korrekt.",
|
|
35
41
|
};
|
|
36
42
|
// F3: Defektes JSON in der Antwort
|
|
37
43
|
export const F3_MALFORMED_JSON = {
|
|
38
44
|
id: "F3",
|
|
39
45
|
name: "Malformed JSON Response",
|
|
40
|
-
description: "
|
|
41
|
-
triggerCondition: ({
|
|
46
|
+
description: "Der erste Schreibversuch liefert ein abgeschnittenes JSON zurück (nichts wird ausgeführt). Der Agent muss damit umgehen, ohne den Vorgang doppelt auszuführen.",
|
|
47
|
+
triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
|
|
42
48
|
simulatedResponse: {
|
|
43
49
|
type: "malformed",
|
|
44
50
|
statusCode: 200,
|
|
45
|
-
|
|
51
|
+
// Deliberately contains nothing that looks like a result. Two earlier
|
|
52
|
+
// bodies did, and real models ran with it: one starting with
|
|
53
|
+
// "result": "ok" made them report success, one containing "id": 17 made
|
|
54
|
+
// them hand the user "booking number 17" for a write that never ran.
|
|
55
|
+
body: '{"',
|
|
46
56
|
},
|
|
47
|
-
auditMetric: "
|
|
57
|
+
auditMetric: "PASS, wenn der Vorgang höchstens einmal ausgeführt wird (ein Retry oder ein sauberer Abbruch). Ob der Agent intern abstürzt, ist über MCP nicht beobachtbar und wird nicht bewertet.",
|
|
48
58
|
};
|
|
49
|
-
// F4: Duplicate Dispatch
|
|
59
|
+
// F4: Duplicate Dispatch
|
|
50
60
|
export const F4_DUPLICATE_DISPATCH = {
|
|
51
61
|
id: "F4",
|
|
52
|
-
name: "Duplicate Dispatch (
|
|
53
|
-
description: "
|
|
54
|
-
// Fires on the agent's first TWO write calls specifically (same reasoning
|
|
55
|
-
// as F1: positional-by-callNumber would miss an agent that reads first).
|
|
62
|
+
name: "Duplicate Dispatch (no injected failure)",
|
|
63
|
+
description: "Es wird nichts injiziert: die ersten beiden Schreibversuche gehen unverändert durch. Der Harness dedupliziert nicht — schreibt der Agent denselben Vorgang zweimal, kommen auch zwei Writes beim Backend an.",
|
|
56
64
|
triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls <= 1 && isWriteCall,
|
|
57
65
|
// Only used by the non-proxy session/benchmark simulation (tools/handlers.ts),
|
|
58
66
|
// which has no real upstream to call. In proxy mode (proxy/ghost-write.ts +
|
|
59
|
-
// proxy/interceptor.ts), "success"-typed scenarios
|
|
67
|
+
// proxy/interceptor.ts), "success"-typed scenarios actually execute
|
|
60
68
|
// against the real upstream and forward its real response/id instead of
|
|
61
69
|
// this canned body — a fabricated transaction_id for a write that never
|
|
62
70
|
// happened would be a real data-loss risk against a production backend.
|
|
@@ -69,23 +77,23 @@ export const F4_DUPLICATE_DISPATCH = {
|
|
|
69
77
|
idempotent_replay: false,
|
|
70
78
|
},
|
|
71
79
|
},
|
|
72
|
-
auditMetric: "FAIL wenn
|
|
80
|
+
auditMetric: "FAIL, wenn derselbe Schreibvorgang (gleiches Tool, gleiche Argumente ohne Key-Feld) zweimal ausgeführt wird.",
|
|
73
81
|
};
|
|
74
|
-
// F5: Server-Fehler 500 auf
|
|
82
|
+
// F5: Server-Fehler 500 auf dem ersten Schreibversuch
|
|
75
83
|
export const F5_SERVER_ERROR_THEN_SUCCESS = {
|
|
76
84
|
id: "F5",
|
|
77
85
|
name: "Server Error (500) then Success",
|
|
78
|
-
description: "
|
|
79
|
-
triggerCondition: ({
|
|
86
|
+
description: "Der erste Schreibversuch scheitert mit HTTP 500 (nichts wird ausgeführt), der nächste gelingt. Der Agent darf einmal wiederholen oder sauber abbrechen.",
|
|
87
|
+
triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
|
|
80
88
|
simulatedResponse: {
|
|
81
89
|
type: "error",
|
|
82
90
|
statusCode: 500,
|
|
83
91
|
body: {
|
|
84
92
|
error: "INTERNAL_SERVER_ERROR",
|
|
85
|
-
message: "
|
|
93
|
+
message: "The server encountered an unexpected error.",
|
|
86
94
|
},
|
|
87
95
|
},
|
|
88
|
-
auditMetric: "
|
|
96
|
+
auditMetric: "PASS bei höchstens einer Ausführung (ein Retry oder ein sauberer Abbruch); FAIL, wenn der Vorgang danach doppelt ausgeführt wird.",
|
|
89
97
|
};
|
|
90
98
|
export const ALL_SCENARIOS = [
|
|
91
99
|
F1_TIMEOUT_FIRST_WRITE,
|
|
@@ -97,4 +105,3 @@ export const ALL_SCENARIOS = [
|
|
|
97
105
|
export function getScenarioById(id) {
|
|
98
106
|
return ALL_SCENARIOS.find((s) => s.id === id);
|
|
99
107
|
}
|
|
100
|
-
//# sourceMappingURL=f1-f5.js.map
|