@wix/pathgrade 1.0.25 → 1.0.27
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 +44 -2
- package/dist/agents/claude/sdk-message-projector.js +5 -0
- package/dist/agents/claude/sdk-options.d.ts +1 -2
- package/dist/agents/claude.js +2 -1
- package/dist/agents/codex-app-server/agent.js +89 -52
- package/dist/agents/codex-app-server/scenario-mount.d.ts +40 -0
- package/dist/agents/codex-app-server/scenario-mount.js +238 -0
- package/dist/agents/codex.js +1 -0
- package/dist/agents/cursor-scenario.d.ts +20 -0
- package/dist/agents/cursor-scenario.js +96 -0
- package/dist/agents/cursor.js +21 -0
- package/dist/agents/opencode/scenario.d.ts +25 -0
- package/dist/agents/opencode/scenario.js +195 -0
- package/dist/agents/opencode.js +54 -2
- package/dist/commands/report.d.ts +10 -2
- package/dist/commands/report.js +25 -4
- package/dist/internal/direct-mcp-v2/acp-author-projector.d.ts +20 -0
- package/dist/internal/direct-mcp-v2/acp-author-projector.js +147 -0
- package/dist/internal/direct-mcp-v2/acp-author-session.d.ts +39 -0
- package/dist/internal/direct-mcp-v2/acp-author-session.js +201 -0
- package/dist/internal/direct-mcp-v2/acp-client.d.ts +70 -0
- package/dist/internal/direct-mcp-v2/acp-client.js +310 -0
- package/dist/internal/direct-mcp-v2/acp-direct-mcp.d.ts +38 -0
- package/dist/internal/direct-mcp-v2/acp-direct-mcp.js +326 -0
- package/dist/internal/direct-mcp-v2/claude-direct-mcp.d.ts +68 -0
- package/dist/internal/direct-mcp-v2/claude-direct-mcp.js +456 -0
- package/dist/internal/direct-mcp-v2/claude-profile.d.ts +9 -0
- package/dist/internal/direct-mcp-v2/claude-profile.js +97 -0
- package/dist/internal/direct-mcp-v2/cleanup.d.ts +62 -0
- package/dist/internal/direct-mcp-v2/cleanup.js +182 -0
- package/dist/internal/direct-mcp-v2/codex-direct-mcp.d.ts +41 -0
- package/dist/internal/direct-mcp-v2/codex-direct-mcp.js +436 -0
- package/dist/internal/direct-mcp-v2/codex-profile.d.ts +12 -0
- package/dist/internal/direct-mcp-v2/codex-profile.js +81 -0
- package/dist/internal/direct-mcp-v2/compiler.d.ts +12 -0
- package/dist/internal/direct-mcp-v2/compiler.js +449 -0
- package/dist/internal/direct-mcp-v2/cursor-direct-mcp.d.ts +14 -0
- package/dist/internal/direct-mcp-v2/cursor-direct-mcp.js +60 -0
- package/dist/internal/direct-mcp-v2/cursor-profile.d.ts +38 -0
- package/dist/internal/direct-mcp-v2/cursor-profile.js +79 -0
- package/dist/internal/direct-mcp-v2/evidence.d.ts +50 -0
- package/dist/internal/direct-mcp-v2/evidence.js +147 -0
- package/dist/internal/direct-mcp-v2/host-reducer.d.ts +61 -0
- package/dist/internal/direct-mcp-v2/host-reducer.js +214 -0
- package/dist/internal/direct-mcp-v2/json.d.ts +9 -0
- package/dist/internal/direct-mcp-v2/json.js +220 -0
- package/dist/internal/direct-mcp-v2/materialize.d.ts +39 -0
- package/dist/internal/direct-mcp-v2/materialize.js +22 -0
- package/dist/internal/direct-mcp-v2/opencode-direct-mcp.d.ts +14 -0
- package/dist/internal/direct-mcp-v2/opencode-direct-mcp.js +71 -0
- package/dist/internal/direct-mcp-v2/opencode-profile.d.ts +8 -0
- package/dist/internal/direct-mcp-v2/opencode-profile.js +52 -0
- package/dist/internal/direct-mcp-v2/projection.d.ts +40 -0
- package/dist/internal/direct-mcp-v2/projection.js +46 -0
- package/dist/internal/direct-mcp-v2/public-scenario-runtime.d.ts +51 -0
- package/dist/internal/direct-mcp-v2/public-scenario-runtime.js +353 -0
- package/dist/internal/direct-mcp-v2/runtime-lock.d.ts +51 -0
- package/dist/internal/direct-mcp-v2/runtime-lock.js +203 -0
- package/dist/internal/direct-mcp-v2/runtime-profile.d.ts +28 -0
- package/dist/internal/direct-mcp-v2/runtime-profile.js +163 -0
- package/dist/internal/direct-mcp-v2/scenario-http-host.d.ts +24 -0
- package/dist/internal/direct-mcp-v2/scenario-http-host.js +254 -0
- package/dist/internal/direct-mcp-v2/types.d.ts +233 -0
- package/dist/internal/direct-mcp-v2/types.js +1 -0
- package/dist/pathgrade.js +22 -1
- package/dist/reporters/github-comment.d.ts +12 -3
- package/dist/reporters/github-comment.js +80 -9
- package/dist/reporting/artifacts.js +5 -2
- package/dist/sdk/agent.js +56 -4
- package/dist/sdk/evaluate.d.ts +2 -0
- package/dist/sdk/evaluate.js +20 -12
- package/dist/sdk/index.d.ts +6 -0
- package/dist/sdk/index.js +4 -0
- package/dist/sdk/lifecycle.js +8 -3
- package/dist/sdk/managed-session.d.ts +6 -0
- package/dist/sdk/managed-session.js +157 -50
- package/dist/sdk/scenario-evidence.d.ts +27 -0
- package/dist/sdk/scenario-evidence.js +94 -0
- package/dist/sdk/scenario-machine-v2.d.ts +9 -0
- package/dist/sdk/scenario-machine-v2.js +37 -0
- package/dist/sdk/snapshots.d.ts +3 -0
- package/dist/sdk/snapshots.js +2 -0
- package/dist/sdk/types.d.ts +11 -1
- package/dist/tool-event-results.d.ts +1 -1
- package/dist/tool-event-results.js +2 -1
- package/dist/tool-events.d.ts +5 -0
- package/dist/tool-events.js +5 -0
- package/dist/types.d.ts +8 -0
- package/package.json +2 -2
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- ACP frames are an external JSON-RPC trust boundary. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
function isJsonObject(value) {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function jsonValue(value) {
|
|
8
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
11
|
+
return value;
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.map(jsonValue);
|
|
14
|
+
if (value !== null && typeof value === 'object')
|
|
15
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonValue(entry)]));
|
|
16
|
+
throw new Error('ACP response was not JSON');
|
|
17
|
+
}
|
|
18
|
+
function jsonObject(value) {
|
|
19
|
+
const parsed = jsonValue(value);
|
|
20
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
21
|
+
throw new Error('ACP frame was not a JSON object');
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
function errorFromResponse(method, value) {
|
|
25
|
+
if (!isJsonObject(value))
|
|
26
|
+
return new Error(`${method} failed with an invalid JSON-RPC error`);
|
|
27
|
+
const rawMessage = value.message;
|
|
28
|
+
const code = value.code;
|
|
29
|
+
const message = typeof rawMessage === 'string' ? rawMessage : 'unknown ACP error';
|
|
30
|
+
return Object.assign(new Error(`${method} failed: ${message}`), {
|
|
31
|
+
code: typeof code === 'number' || typeof code === 'string' ? code : undefined,
|
|
32
|
+
data: value.data,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export class AcpClientCore {
|
|
36
|
+
sendFrame;
|
|
37
|
+
onRequest;
|
|
38
|
+
nextId = 1;
|
|
39
|
+
pending = new Map();
|
|
40
|
+
notifications = [];
|
|
41
|
+
nextNotificationSequence = 0;
|
|
42
|
+
closed = false;
|
|
43
|
+
closeReason = 'ACP client is closed';
|
|
44
|
+
constructor(sendFrame, onRequest) {
|
|
45
|
+
this.sendFrame = sendFrame;
|
|
46
|
+
this.onRequest = onRequest;
|
|
47
|
+
}
|
|
48
|
+
request(method, params, timeoutMs = 120_000) {
|
|
49
|
+
if (this.closed)
|
|
50
|
+
return Promise.reject(new Error(this.closeReason));
|
|
51
|
+
const id = this.nextId++;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
this.pending.delete(id);
|
|
55
|
+
reject(new Error(`ACP ${method} timed out after ${timeoutMs}ms`));
|
|
56
|
+
}, timeoutMs);
|
|
57
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
58
|
+
this.sendFrame({ jsonrpc: '2.0', id, method, params });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
notify(method, params) {
|
|
62
|
+
if (this.closed)
|
|
63
|
+
throw new Error(this.closeReason);
|
|
64
|
+
this.sendFrame({ jsonrpc: '2.0', method, params });
|
|
65
|
+
}
|
|
66
|
+
async receiveLine(line) {
|
|
67
|
+
let message;
|
|
68
|
+
try {
|
|
69
|
+
message = jsonObject(JSON.parse(line));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error('ACP emitted a non-JSON frame');
|
|
73
|
+
}
|
|
74
|
+
if (message.jsonrpc !== '2.0')
|
|
75
|
+
throw new Error('ACP emitted an invalid JSON-RPC frame');
|
|
76
|
+
const rawId = message.id;
|
|
77
|
+
const id = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : undefined;
|
|
78
|
+
const rawMethod = message.method;
|
|
79
|
+
if (id !== undefined && rawMethod === undefined) {
|
|
80
|
+
const pending = this.pending.get(id);
|
|
81
|
+
if (!pending)
|
|
82
|
+
return;
|
|
83
|
+
this.pending.delete(id);
|
|
84
|
+
clearTimeout(pending.timer);
|
|
85
|
+
const responseError = message.error;
|
|
86
|
+
if (responseError !== undefined)
|
|
87
|
+
pending.reject(errorFromResponse(pending.method, responseError));
|
|
88
|
+
else
|
|
89
|
+
pending.resolve(message.result ?? null);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (typeof rawMethod !== 'string')
|
|
93
|
+
throw new Error('ACP JSON-RPC frame has no method');
|
|
94
|
+
if (id === undefined) {
|
|
95
|
+
this.notifications.push({ sequence: this.nextNotificationSequence++, message: jsonObject(message) });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.onRequest(rawMethod, message.params ?? null);
|
|
100
|
+
this.sendFrame({ jsonrpc: '2.0', id, result });
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.sendFrame({ jsonrpc: '2.0', id, error: { code: -32601, message: error instanceof Error ? error.message : String(error) } });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
observedNotifications() {
|
|
107
|
+
return this.notifications.map(({ message }) => ({ ...message }));
|
|
108
|
+
}
|
|
109
|
+
notificationCheckpoint() {
|
|
110
|
+
return this.nextNotificationSequence;
|
|
111
|
+
}
|
|
112
|
+
drainNotifications(checkpoint) {
|
|
113
|
+
if (!Number.isSafeInteger(checkpoint) || checkpoint < 0 || checkpoint > this.nextNotificationSequence) {
|
|
114
|
+
throw new Error('invalid ACP notification checkpoint');
|
|
115
|
+
}
|
|
116
|
+
const drained = [];
|
|
117
|
+
for (let index = this.notifications.length - 1; index >= 0; index--) {
|
|
118
|
+
const entry = this.notifications[index];
|
|
119
|
+
if (entry.sequence < checkpoint)
|
|
120
|
+
continue;
|
|
121
|
+
drained.unshift({ ...entry.message });
|
|
122
|
+
this.notifications.splice(index, 1);
|
|
123
|
+
}
|
|
124
|
+
return drained;
|
|
125
|
+
}
|
|
126
|
+
close(reason = 'ACP client closed') {
|
|
127
|
+
if (this.closed)
|
|
128
|
+
return;
|
|
129
|
+
this.closed = true;
|
|
130
|
+
this.closeReason = reason;
|
|
131
|
+
for (const request of this.pending.values()) {
|
|
132
|
+
clearTimeout(request.timer);
|
|
133
|
+
request.reject(new Error(reason));
|
|
134
|
+
}
|
|
135
|
+
this.pending.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function normalizeHandshake(provider, value) {
|
|
139
|
+
if (!isJsonObject(value) || value.protocolVersion !== 1 || !isJsonObject(value.agentCapabilities))
|
|
140
|
+
throw new Error('ACP initialize did not return protocol v1 capabilities');
|
|
141
|
+
const mcp = value.agentCapabilities.mcpCapabilities;
|
|
142
|
+
if (!isJsonObject(mcp) || mcp.http !== true)
|
|
143
|
+
throw new Error('ACP initialize did not advertise HTTP MCP');
|
|
144
|
+
const session = value.agentCapabilities.sessionCapabilities;
|
|
145
|
+
return {
|
|
146
|
+
normalizerVersion: 'pathgrade-acp-v1-capabilities-v1',
|
|
147
|
+
provider,
|
|
148
|
+
protocolVersion: 1,
|
|
149
|
+
httpMcp: true,
|
|
150
|
+
sessionClose: session !== undefined && isJsonObject(session) && session.close !== undefined && isJsonObject(session.close),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function readSessionId(value) {
|
|
154
|
+
const sessionId = isJsonObject(value) ? value.sessionId : undefined;
|
|
155
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
156
|
+
throw new Error('ACP session/new returned no sessionId');
|
|
157
|
+
return sessionId;
|
|
158
|
+
}
|
|
159
|
+
function readPromptResult(value) {
|
|
160
|
+
const stopReason = isJsonObject(value) ? value.stopReason : undefined;
|
|
161
|
+
if (typeof stopReason !== 'string')
|
|
162
|
+
throw new Error('ACP session/prompt returned no stopReason');
|
|
163
|
+
return { stopReason };
|
|
164
|
+
}
|
|
165
|
+
function permissionRequest(params) {
|
|
166
|
+
if (!isJsonObject(params))
|
|
167
|
+
throw new Error('invalid ACP permission request');
|
|
168
|
+
const toolCall = params.toolCall !== undefined && isJsonObject(params.toolCall) ? params.toolCall : undefined;
|
|
169
|
+
const rawOptions = params.options;
|
|
170
|
+
if (!Array.isArray(rawOptions))
|
|
171
|
+
throw new Error('invalid ACP permission options');
|
|
172
|
+
const options = rawOptions.map((entry) => {
|
|
173
|
+
const optionId = isJsonObject(entry) ? entry.optionId : undefined;
|
|
174
|
+
const kind = isJsonObject(entry) ? entry.kind : undefined;
|
|
175
|
+
if (typeof optionId !== 'string' || typeof kind !== 'string')
|
|
176
|
+
throw new Error('invalid ACP permission option');
|
|
177
|
+
return { optionId, kind };
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
sessionId: typeof params.sessionId === 'string' ? params.sessionId : undefined,
|
|
181
|
+
toolCallId: toolCall && typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : undefined,
|
|
182
|
+
options,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function includesProtectedValue(value, protectedValues) {
|
|
186
|
+
return protectedValues.some((secret) => secret.length > 0 && value.includes(secret));
|
|
187
|
+
}
|
|
188
|
+
export function spawnAcpClient(options) {
|
|
189
|
+
const argv = [options.command, ...options.args].join('\u0000');
|
|
190
|
+
const environmentText = Object.entries(options.environment).map(([name, value]) => `${name}=${value ?? ''}`).join('\u0000');
|
|
191
|
+
if (includesProtectedValue(argv, options.protectedValues) || includesProtectedValue(environmentText, options.protectedValues)) {
|
|
192
|
+
throw new Error('ACP protected value reached child argv or environment');
|
|
193
|
+
}
|
|
194
|
+
const spawnProcess = options.spawnProcess ?? ((command, args, childOptions) => spawn(command, [...args], childOptions));
|
|
195
|
+
const child = spawnProcess(options.command, options.args, { cwd: options.cwd, env: options.environment, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
196
|
+
let open = true;
|
|
197
|
+
let cancelling = false;
|
|
198
|
+
let sessionClose = false;
|
|
199
|
+
let sessionId;
|
|
200
|
+
let outboundSecretFrames = 0;
|
|
201
|
+
let reflectedSecret = false;
|
|
202
|
+
const exit = new Promise((resolve) => {
|
|
203
|
+
child.once('exit', (code, signal) => {
|
|
204
|
+
open = false;
|
|
205
|
+
core.close(`ACP provider exited (${code ?? signal ?? 'unknown'})`);
|
|
206
|
+
resolve();
|
|
207
|
+
});
|
|
208
|
+
child.once('error', (error) => {
|
|
209
|
+
open = false;
|
|
210
|
+
core.close(`ACP provider failed: ${error.message}`);
|
|
211
|
+
resolve();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
const core = new AcpClientCore((message) => {
|
|
215
|
+
const frame = JSON.stringify(message);
|
|
216
|
+
if (includesProtectedValue(frame, options.protectedValues))
|
|
217
|
+
outboundSecretFrames += 1;
|
|
218
|
+
child.stdin.write(`${frame}\n`);
|
|
219
|
+
}, async (method, params) => {
|
|
220
|
+
if (method !== 'session/request_permission')
|
|
221
|
+
throw new Error(`unsupported ACP client request ${method}`);
|
|
222
|
+
const request = permissionRequest(params);
|
|
223
|
+
if (cancelling)
|
|
224
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
225
|
+
const allowed = request.options.find((entry) => entry.kind === 'allow_once');
|
|
226
|
+
if (!allowed)
|
|
227
|
+
throw new Error('ACP permission request has no allow_once option');
|
|
228
|
+
return { outcome: { outcome: 'selected', optionId: allowed.optionId } };
|
|
229
|
+
});
|
|
230
|
+
const lines = createInterface({ input: child.stdout });
|
|
231
|
+
lines.on('line', (line) => {
|
|
232
|
+
if (includesProtectedValue(line, options.protectedValues)) {
|
|
233
|
+
reflectedSecret = true;
|
|
234
|
+
core.close('ACP provider reflected a protected control-plane value');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
void core.receiveLine(line).catch((error) => core.close(error instanceof Error ? error.message : String(error)));
|
|
238
|
+
});
|
|
239
|
+
child.stderr.setEncoding('utf8');
|
|
240
|
+
child.stderr.on('data', (chunk) => {
|
|
241
|
+
if (includesProtectedValue(chunk, options.protectedValues)) {
|
|
242
|
+
reflectedSecret = true;
|
|
243
|
+
core.close('ACP provider reflected a protected control-plane value');
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
async initialize() {
|
|
248
|
+
const raw = await core.request('initialize', {
|
|
249
|
+
protocolVersion: 1,
|
|
250
|
+
clientCapabilities: {},
|
|
251
|
+
clientInfo: { name: 'pathgrade-direct-mcp', title: 'Pathgrade direct MCP', version: '2.0.0' },
|
|
252
|
+
}, 30_000);
|
|
253
|
+
const normalized = normalizeHandshake(options.provider, raw);
|
|
254
|
+
sessionClose = normalized.sessionClose;
|
|
255
|
+
return normalized;
|
|
256
|
+
},
|
|
257
|
+
async newSession(mcpServers) {
|
|
258
|
+
sessionId = readSessionId(await core.request('session/new', { cwd: options.cwd, mcpServers: [...mcpServers] }, 30_000));
|
|
259
|
+
return sessionId;
|
|
260
|
+
},
|
|
261
|
+
async prompt(currentSessionId, text) {
|
|
262
|
+
return readPromptResult(await core.request('session/prompt', { sessionId: currentSessionId, prompt: [{ type: 'text', text }] }, 180_000));
|
|
263
|
+
},
|
|
264
|
+
cancel(currentSessionId) { core.notify('session/cancel', { sessionId: currentSessionId }); },
|
|
265
|
+
async close() {
|
|
266
|
+
if (!open) {
|
|
267
|
+
await exit;
|
|
268
|
+
if (reflectedSecret)
|
|
269
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (sessionId && sessionClose)
|
|
273
|
+
await core.request('session/close', { sessionId }, 10_000).catch(() => undefined);
|
|
274
|
+
core.close();
|
|
275
|
+
lines.close();
|
|
276
|
+
child.stdin.end();
|
|
277
|
+
if (await waitForExit(exit, 1_000)) {
|
|
278
|
+
if (reflectedSecret)
|
|
279
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
child.kill('SIGTERM');
|
|
283
|
+
if (await waitForExit(exit, 2_000)) {
|
|
284
|
+
if (reflectedSecret)
|
|
285
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
child.kill('SIGKILL');
|
|
289
|
+
if (!await waitForExit(exit, 2_000))
|
|
290
|
+
throw new Error('ACP provider did not exit after SIGKILL');
|
|
291
|
+
if (reflectedSecret)
|
|
292
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
293
|
+
},
|
|
294
|
+
setCancelling(value) { cancelling = value; },
|
|
295
|
+
isOpen: () => open,
|
|
296
|
+
controlPlaneSecretFrames: () => outboundSecretFrames,
|
|
297
|
+
inboundSecretSeen: () => reflectedSecret,
|
|
298
|
+
notificationCheckpoint: () => core.notificationCheckpoint(),
|
|
299
|
+
drainNotifications: (checkpoint) => core.drainNotifications(checkpoint),
|
|
300
|
+
notifications: () => core.observedNotifications(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async function waitForExit(exit, timeoutMs) {
|
|
304
|
+
let timer;
|
|
305
|
+
return Promise.race([
|
|
306
|
+
exit.then(() => true),
|
|
307
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
|
|
308
|
+
]).finally(() => { if (timer)
|
|
309
|
+
clearTimeout(timer); });
|
|
310
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawnAcpClient } from './acp-client.js';
|
|
2
|
+
import { type CleanupReport } from './cleanup.js';
|
|
3
|
+
import type { EvidenceEnvelope, RuntimeCapabilityProfile, ScenarioArtifact, ScenarioMachineV2 } from './types.js';
|
|
4
|
+
export type AcpDirectMcpChecks = {
|
|
5
|
+
runtimeAdmitted: boolean;
|
|
6
|
+
secretFreeProject: boolean;
|
|
7
|
+
directCall: boolean;
|
|
8
|
+
structuredToolError: boolean;
|
|
9
|
+
fencedCancellation: boolean;
|
|
10
|
+
providerTerminal: boolean;
|
|
11
|
+
hostQuiescent: boolean;
|
|
12
|
+
postCancelReuse: boolean;
|
|
13
|
+
privateReplay: boolean;
|
|
14
|
+
cleanup: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type AcpDirectMcpTrialResult = {
|
|
17
|
+
selected: true;
|
|
18
|
+
profileFingerprint: string;
|
|
19
|
+
artifact: ScenarioArtifact;
|
|
20
|
+
evidence: readonly EvidenceEnvelope[];
|
|
21
|
+
checks: AcpDirectMcpChecks;
|
|
22
|
+
cleanup: CleanupReport;
|
|
23
|
+
cancellationStatus: string;
|
|
24
|
+
};
|
|
25
|
+
export type AcpDirectMcpOptions = {
|
|
26
|
+
provider: 'cursor' | 'opencode';
|
|
27
|
+
scenario: ScenarioMachineV2;
|
|
28
|
+
environment: NodeJS.ProcessEnv;
|
|
29
|
+
profile: RuntimeCapabilityProfile;
|
|
30
|
+
artifactDirectory?: string;
|
|
31
|
+
createClient?: typeof spawnAcpClient;
|
|
32
|
+
localHomeLinks?: readonly {
|
|
33
|
+
relativePath: string;
|
|
34
|
+
hostPath: string;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
export declare function curateAcpProcessEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
38
|
+
export declare function runAcpDirectMcpTrial(options: AcpDirectMcpOptions): Promise<AcpDirectMcpTrialResult>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters -- Provider promise rejections are normalized at the ACP boundary. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
6
|
+
import { SAFE_HOST_VARS } from '../../providers/sandbox.js';
|
|
7
|
+
import { spawnAcpClient } from './acp-client.js';
|
|
8
|
+
import { IdempotentTrialCleaner, persistTrialArtifact, TrialExecutionError, TrialResourceRegistry, scanBearerSurfaces } from './cleanup.js';
|
|
9
|
+
import { compileScenario } from './compiler.js';
|
|
10
|
+
import { PrivateReplayVault, replayPrivateEvidence, verifyEvidenceChain } from './evidence.js';
|
|
11
|
+
import { ScenarioHostReducer } from './host-reducer.js';
|
|
12
|
+
import { materializeProjection } from './materialize.js';
|
|
13
|
+
import { projectEndpoints } from './projection.js';
|
|
14
|
+
import { admitRuntimeProfile } from './runtime-profile.js';
|
|
15
|
+
import { createInvocationBarrier, startScenarioHttpHost } from './scenario-http-host.js';
|
|
16
|
+
export function curateAcpProcessEnvironment(environment) {
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const name of SAFE_HOST_VARS)
|
|
19
|
+
if (environment[name])
|
|
20
|
+
result[name] = environment[name];
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export async function runAcpDirectMcpTrial(options) {
|
|
24
|
+
const compiled = compileScenario(options.scenario);
|
|
25
|
+
if (!compiled.ok)
|
|
26
|
+
throw new Error(`Scenario compilation failed: ${JSON.stringify(compiled.diagnostics)}`);
|
|
27
|
+
const cases = findContractCases(compiled.artifact);
|
|
28
|
+
const createdScratchParent = await mkdtemp(join(tmpdir(), `pathgrade-${options.provider}-direct-mcp-`));
|
|
29
|
+
let scratchParent;
|
|
30
|
+
try {
|
|
31
|
+
scratchParent = await realpath(createdScratchParent);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await rm(createdScratchParent, { recursive: true, force: true }).catch(() => undefined);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const scratchPath = join(scratchParent, 'trial');
|
|
38
|
+
const workspacePath = join(scratchPath, 'workspace');
|
|
39
|
+
const homePath = join(scratchPath, 'home');
|
|
40
|
+
const dataPath = join(scratchPath, 'data');
|
|
41
|
+
const configPath = join(scratchPath, 'config');
|
|
42
|
+
const cachePath = join(scratchPath, 'cache');
|
|
43
|
+
const tempPath = join(scratchPath, 'tmp');
|
|
44
|
+
try {
|
|
45
|
+
await Promise.all([workspacePath, homePath, dataPath, configPath, cachePath, tempPath].map((path) => mkdir(path, { recursive: true })));
|
|
46
|
+
for (const link of options.localHomeLinks ?? []) {
|
|
47
|
+
const difference = relative(homePath, join(homePath, link.relativePath));
|
|
48
|
+
if (link.relativePath.length === 0 || isAbsolute(link.relativePath) || difference.startsWith('..'))
|
|
49
|
+
throw new Error(`unsafe local HOME link: ${link.relativePath}`);
|
|
50
|
+
const source = await realpath(link.hostPath);
|
|
51
|
+
const destination = join(homePath, link.relativePath);
|
|
52
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
53
|
+
await symlink(source, destination, 'dir');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
await rm(scratchParent, { recursive: true, force: true }).catch(() => undefined);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const runtimeEnvironment = {
|
|
61
|
+
...options.environment,
|
|
62
|
+
HOME: homePath,
|
|
63
|
+
TMPDIR: tempPath,
|
|
64
|
+
TMP: tempPath,
|
|
65
|
+
TEMP: tempPath,
|
|
66
|
+
XDG_DATA_HOME: dataPath,
|
|
67
|
+
XDG_CONFIG_HOME: configPath,
|
|
68
|
+
XDG_CACHE_HOME: cachePath,
|
|
69
|
+
};
|
|
70
|
+
delete runtimeEnvironment.OPENCODE_CONFIG_CONTENT;
|
|
71
|
+
const trialId = `${options.provider}-${randomBytes(12).toString('hex')}`;
|
|
72
|
+
const macKey = randomBytes(32);
|
|
73
|
+
const replayVault = new PrivateReplayVault(randomBytes(32));
|
|
74
|
+
const reducer = new ScenarioHostReducer(trialId, compiled.artifact, macKey, replayVault);
|
|
75
|
+
reducer.markCompiled();
|
|
76
|
+
const barrier = createInvocationBarrier(cases.effectfulCaseId);
|
|
77
|
+
const host = await startScenarioHttpHost({ artifact: compiled.artifact, reducer, barrier, secretRef: `${options.provider}-direct-mcp-trial-bearer` }).catch(async (error) => {
|
|
78
|
+
macKey.fill(0);
|
|
79
|
+
replayVault.dispose();
|
|
80
|
+
reducer.dispose();
|
|
81
|
+
await rm(scratchParent, { recursive: true, force: true }).catch(() => undefined);
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
const registry = new TrialResourceRegistry();
|
|
85
|
+
let hostOpen = true;
|
|
86
|
+
let client;
|
|
87
|
+
let hostQuiescent = false;
|
|
88
|
+
let persisted = false;
|
|
89
|
+
registry.register({ opaqueId: 'scenario-host', kind: 'listener', close: async () => { await host.dispose(); hostOpen = false; }, isOpen: () => hostOpen });
|
|
90
|
+
registry.register({ opaqueId: 'scenario-transports', kind: 'transport', close: () => host.dispose(), isOpen: () => host.activeTransportCount() !== 0 });
|
|
91
|
+
const cleaner = new IdempotentTrialCleaner({
|
|
92
|
+
registry,
|
|
93
|
+
scanRoots: [scratchPath],
|
|
94
|
+
scratchPath,
|
|
95
|
+
approvedScratchParent: scratchParent,
|
|
96
|
+
bearer: host.bearer.toString(),
|
|
97
|
+
persistRedacted: async (report, phase) => {
|
|
98
|
+
if (!options.artifactDirectory) {
|
|
99
|
+
if (phase === 'final')
|
|
100
|
+
persisted = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const path = join(options.artifactDirectory, 'trial-evidence.json');
|
|
104
|
+
await mkdir(dirname(path), { recursive: true });
|
|
105
|
+
const value = {
|
|
106
|
+
artifactDigest: compiled.artifact.artifactDigest,
|
|
107
|
+
scenarioArtifact: compiled.artifact,
|
|
108
|
+
evidence: reducer.ledger.events(),
|
|
109
|
+
replayVaultReference: {
|
|
110
|
+
format: 'pathgrade-private-replay-vault-reference-v1',
|
|
111
|
+
trialId,
|
|
112
|
+
artifactDigest: compiled.artifact.artifactDigest,
|
|
113
|
+
},
|
|
114
|
+
admissionProfile: options.profile,
|
|
115
|
+
cleanup: { ...report, scans: report.scans.map((entry) => ({ ...entry, path: entry.path.replace(scratchParent, '<scratch>') })) },
|
|
116
|
+
};
|
|
117
|
+
await persistTrialArtifact(path, `${JSON.stringify(value)}\n`, phase);
|
|
118
|
+
if (phase === 'final')
|
|
119
|
+
persisted = true;
|
|
120
|
+
},
|
|
121
|
+
zeroBearer: () => { host.bearer.fill(0); macKey.fill(0); replayVault.dispose(); },
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const protectedValues = [host.bearer.toString(), `Bearer ${host.bearer.toString()}`];
|
|
125
|
+
const createClient = options.createClient ?? spawnAcpClient;
|
|
126
|
+
const admission = await admitRuntimeProfile(options.profile, {
|
|
127
|
+
provider: options.provider,
|
|
128
|
+
platform: process.platform,
|
|
129
|
+
architecture: process.arch,
|
|
130
|
+
environment: runtimeEnvironment,
|
|
131
|
+
forbiddenSecrets: protectedValues,
|
|
132
|
+
initializeControlPlane: async () => {
|
|
133
|
+
const initializedClient = createClient({
|
|
134
|
+
provider: options.provider,
|
|
135
|
+
command: options.profile.command.absolutePath,
|
|
136
|
+
args: options.profile.command.args,
|
|
137
|
+
cwd: workspacePath,
|
|
138
|
+
environment: runtimeEnvironment,
|
|
139
|
+
protectedValues,
|
|
140
|
+
});
|
|
141
|
+
client = initializedClient;
|
|
142
|
+
registry.register({ opaqueId: `${options.provider}-acp`, kind: 'process', close: () => initializedClient.close(), isOpen: () => initializedClient.isOpen() });
|
|
143
|
+
const normalizedHandshake = await initializedClient.initialize();
|
|
144
|
+
return { normalizedHandshake, capabilities: options.profile.requiredCapabilities, close: () => initializedClient.close() };
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (!admission.ok || !client)
|
|
148
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: ${admission.ok ? 'ACP client unavailable' : admission.reason}`);
|
|
149
|
+
const admittedClient = client;
|
|
150
|
+
const projected = projectEndpoints(host.endpoints, admission.profile);
|
|
151
|
+
if (!Array.isArray(projected))
|
|
152
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: ${projected.reason}`);
|
|
153
|
+
const mcpServers = projected.flatMap((plan) => {
|
|
154
|
+
const materialized = materializeProjection(plan, {
|
|
155
|
+
resolve(secretRef) {
|
|
156
|
+
if (secretRef !== host.secretRef)
|
|
157
|
+
throw new Error(`Unknown secret reference: ${secretRef}`);
|
|
158
|
+
return `Bearer ${host.bearer.toString()}`;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
if (materialized.kind !== 'acp-session' || materialized.provider !== options.provider)
|
|
162
|
+
throw new Error('ACP projection materialized for another provider');
|
|
163
|
+
return materialized.mcpServers.map((server) => ({
|
|
164
|
+
type: server.type,
|
|
165
|
+
name: server.name,
|
|
166
|
+
url: server.url,
|
|
167
|
+
headers: server.headers.map((header) => ({ name: header.name, value: header.value })),
|
|
168
|
+
}));
|
|
169
|
+
});
|
|
170
|
+
const protectedSurfaces = [...protectedValues, ...host.endpoints.map((endpoint) => endpoint.url)];
|
|
171
|
+
const sessionId = await admittedClient.newSession(mcpServers);
|
|
172
|
+
if (admittedClient.controlPlaneSecretFrames() !== 1 || admittedClient.inboundSecretSeen())
|
|
173
|
+
throw new Error('RUNTIME_NOT_ADMITTED: ACP secret isolation failed during session admission');
|
|
174
|
+
await assertProtectedSurfacesClean([scratchPath], protectedSurfaces, 'RUNTIME_NOT_ADMITTED: ACP protected projection reached a writable or unreadable surface');
|
|
175
|
+
reducer.markAdmitted();
|
|
176
|
+
reducer.markReady();
|
|
177
|
+
reducer.beginTurn('direct-call', options.provider);
|
|
178
|
+
await admittedClient.prompt(sessionId, normalPrompt(options.provider, cases));
|
|
179
|
+
await host.waitForQuiescence(30_000);
|
|
180
|
+
reducer.finalize('settled');
|
|
181
|
+
const normalEvidence = reducer.ledger.events();
|
|
182
|
+
if (!hasDecision(normalEvidence, cases.pureCaseId) || !hasDecision(normalEvidence, cases.errorCaseId))
|
|
183
|
+
throw new Error(`${options.provider} did not exercise direct success and structured tool error`);
|
|
184
|
+
reducer.beginTurn('cancelled-turn', options.provider);
|
|
185
|
+
const cancelled = admittedClient.prompt(sessionId, cancellationPrompt(cases)).then((result) => ({ kind: 'result', result }), (error) => ({ kind: 'error', error }));
|
|
186
|
+
const entered = await withTimeout(Promise.race([barrier.reached().then(() => 'barrier'), cancelled.then((result) => `terminal:${result.kind}`)]), 90_000, `${options.provider} blocking invocation`);
|
|
187
|
+
if (entered !== 'barrier')
|
|
188
|
+
throw new Error(`${options.provider} reached ${entered} before the blocking MCP transition`);
|
|
189
|
+
reducer.fence();
|
|
190
|
+
admittedClient.setCancelling(true);
|
|
191
|
+
admittedClient.cancel(sessionId);
|
|
192
|
+
reducer.abortUncommitted('provider-cancelled');
|
|
193
|
+
barrier.release();
|
|
194
|
+
const cancellation = await withTimeout(cancelled, 45_000, `${options.provider} cancelled prompt`);
|
|
195
|
+
admittedClient.setCancelling(false);
|
|
196
|
+
if (cancellation.kind === 'error')
|
|
197
|
+
throw cancellation.error;
|
|
198
|
+
await host.waitForQuiescence(30_000);
|
|
199
|
+
hostQuiescent = host.activeTransportCount() === 0;
|
|
200
|
+
if (!hostQuiescent)
|
|
201
|
+
throw new Error(`Scenario host was not quiescent after ${options.provider} cancellation`);
|
|
202
|
+
if (cancellation.result.stopReason !== 'cancelled')
|
|
203
|
+
throw new Error(`${options.provider} cancellation terminal was ${cancellation.result.stopReason}`);
|
|
204
|
+
reducer.observeProviderTerminal();
|
|
205
|
+
reducer.finalize('cancelled-clean');
|
|
206
|
+
reducer.beginTurn('post-cancel-reuse', options.provider);
|
|
207
|
+
await admittedClient.prompt(sessionId, reusePrompt(cases));
|
|
208
|
+
await host.waitForQuiescence(30_000);
|
|
209
|
+
reducer.finalize('settled');
|
|
210
|
+
if (!hasDecision(reducer.ledger.events(), cases.effectfulCaseId))
|
|
211
|
+
throw new Error(`${options.provider} post-cancel reuse did not reach the scenario host`);
|
|
212
|
+
const replay = replayPrivateEvidence({ artifact: compiled.artifact, evidence: reducer.ledger.events(), vault: replayVault, macKey });
|
|
213
|
+
if (!replay.ok || replay.poisoned || replay.finalState !== reducer.state)
|
|
214
|
+
throw new Error(`Private replay failed: ${replay.ok ? 'state mismatch' : replay.reason}`);
|
|
215
|
+
const chain = verifyEvidenceChain(reducer.ledger.events(), compiled.artifact.artifactDigest);
|
|
216
|
+
if (!chain.ok)
|
|
217
|
+
throw new Error(`Evidence chain failed: ${chain.reason}`);
|
|
218
|
+
await assertProtectedSurfacesClean([scratchPath], protectedSurfaces, 'ACP protected projection reached a writable or unreadable surface');
|
|
219
|
+
reducer.dispose();
|
|
220
|
+
const cleanup = await cleaner.dispose();
|
|
221
|
+
await rm(scratchParent, { recursive: true, force: true });
|
|
222
|
+
if (!persisted || cleanup.failures.length > 0 || cleanup.bearerFound || !cleanup.scratchDeleted)
|
|
223
|
+
throw new Error(`CLEANUP_FAILED: ${JSON.stringify(cleanup.failures)}`);
|
|
224
|
+
return {
|
|
225
|
+
selected: true,
|
|
226
|
+
profileFingerprint: options.profile.profileFingerprint,
|
|
227
|
+
artifact: compiled.artifact,
|
|
228
|
+
evidence: reducer.ledger.events(),
|
|
229
|
+
checks: { runtimeAdmitted: true, secretFreeProject: true, directCall: true, structuredToolError: true, fencedCancellation: true, providerTerminal: true, hostQuiescent, postCancelReuse: true, privateReplay: true, cleanup: true },
|
|
230
|
+
cleanup,
|
|
231
|
+
cancellationStatus: cancellation.result.stopReason,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
barrier.release();
|
|
236
|
+
client?.setCancelling(true);
|
|
237
|
+
try {
|
|
238
|
+
if (reducer.phase === 'ACTIVE')
|
|
239
|
+
reducer.fence();
|
|
240
|
+
if (reducer.phase === 'FENCING')
|
|
241
|
+
reducer.abortUncommitted('trial-failed');
|
|
242
|
+
if (!['DISPOSED', 'NEW', 'COMPILED', 'ADMITTED'].includes(reducer.phase))
|
|
243
|
+
reducer.finalize('poisoned', 'trial-failed');
|
|
244
|
+
}
|
|
245
|
+
catch (lifecycleFailure) {
|
|
246
|
+
void lifecycleFailure;
|
|
247
|
+
}
|
|
248
|
+
reducer.dispose();
|
|
249
|
+
let cleanupError;
|
|
250
|
+
try {
|
|
251
|
+
const report = await cleaner.dispose();
|
|
252
|
+
if (report.failures.length > 0 || report.bearerFound || !report.scratchDeleted)
|
|
253
|
+
cleanupError = new Error(`CLEANUP_FAILED: ${JSON.stringify(report.failures)}`);
|
|
254
|
+
}
|
|
255
|
+
catch (failure) {
|
|
256
|
+
cleanupError = failure instanceof Error ? failure : new Error(String(failure));
|
|
257
|
+
}
|
|
258
|
+
await rm(scratchParent, { recursive: true, force: true }).catch(() => undefined);
|
|
259
|
+
if (cleanupError)
|
|
260
|
+
throw new TrialExecutionError(error instanceof Error ? error.message : `${options.provider} direct MCP trial failed`, { primary: error, secondary: [cleanupError] });
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function assertProtectedSurfacesClean(roots, protectedValues, message) {
|
|
265
|
+
for (const value of protectedValues) {
|
|
266
|
+
const scans = await scanBearerSurfaces(roots, value);
|
|
267
|
+
if (scans.some((entry) => entry.status !== 'clean'))
|
|
268
|
+
throw new Error(message);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function findContractCases(artifact) {
|
|
272
|
+
const state = artifact.canonicalIr.states[artifact.canonicalIr.initialState];
|
|
273
|
+
const entries = Object.entries(state.tools);
|
|
274
|
+
const effectful = entries.find(([, tool]) => tool.effectful && tool.cases.some((entry) => entry.transition && entry.outcome.kind === 'success' && 'exactArgs' in entry.when));
|
|
275
|
+
const error = entries.find(([, tool]) => tool.cases.some((entry) => entry.outcome.kind === 'tool-error' && 'exactArgs' in entry.when));
|
|
276
|
+
const pure = entries.find(([, tool]) => !tool.effectful && tool.cases.some((entry) => entry.outcome.kind === 'success'));
|
|
277
|
+
if (!effectful || !error || !pure)
|
|
278
|
+
throw new Error('ACP vertical requires an effectful success, structured error, and pure success in the initial state');
|
|
279
|
+
const successCase = effectful[1].cases.find((entry) => entry.transition && entry.outcome.kind === 'success' && 'exactArgs' in entry.when);
|
|
280
|
+
const errorCase = error[1].cases.find((entry) => entry.outcome.kind === 'tool-error' && 'exactArgs' in entry.when);
|
|
281
|
+
const pureCase = pure[1].cases.find((entry) => entry.outcome.kind === 'success');
|
|
282
|
+
const [serverId, effectfulToolId] = effectful[0].split('/');
|
|
283
|
+
return {
|
|
284
|
+
serverId,
|
|
285
|
+
effectfulToolId,
|
|
286
|
+
effectfulCaseId: successCase.id,
|
|
287
|
+
successArgs: exactArgs(successCase),
|
|
288
|
+
errorCaseId: errorCase.id,
|
|
289
|
+
errorArgs: exactArgs(errorCase),
|
|
290
|
+
pureToolId: pure[0].split('/')[1],
|
|
291
|
+
pureCaseId: pureCase.id,
|
|
292
|
+
pureArgs: 'exactArgs' in pureCase.when ? pureCase.when.exactArgs : deriveArgs(pureCase.when.all),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function exactArgs(scenarioCase) {
|
|
296
|
+
if (!('exactArgs' in scenarioCase.when))
|
|
297
|
+
throw new Error('ACP contract case requires exact arguments');
|
|
298
|
+
return scenarioCase.when.exactArgs;
|
|
299
|
+
}
|
|
300
|
+
function deriveArgs(predicates) {
|
|
301
|
+
const result = {};
|
|
302
|
+
for (const predicate of predicates)
|
|
303
|
+
if (predicate.pointer.startsWith('/') && 'equals' in predicate)
|
|
304
|
+
result[predicate.pointer.slice(1)] = predicate.equals;
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
function normalPrompt(provider, cases) {
|
|
308
|
+
return `Use only MCP server ${cases.serverId}. Invoke ${cases.pureToolId} exactly once with ${JSON.stringify(cases.pureArgs)}, then invoke ${cases.effectfulToolId} exactly once with ${JSON.stringify(cases.errorArgs)}. The second result is an expected tool error; do not retry it. Do not use shell or any other tool. Then answer ${provider.toUpperCase()}_DIRECT_DONE.`;
|
|
309
|
+
}
|
|
310
|
+
function cancellationPrompt(cases) {
|
|
311
|
+
return `Use only MCP server ${cases.serverId}. Invoke ${cases.effectfulToolId} exactly once with ${JSON.stringify(cases.successArgs)}. Wait for its result and do not use any other tool.`;
|
|
312
|
+
}
|
|
313
|
+
function reusePrompt(cases) {
|
|
314
|
+
return `Use only MCP server ${cases.serverId}. Invoke ${cases.effectfulToolId} exactly once with ${JSON.stringify(cases.successArgs)}. Wait for its result and do not use any other tool.`;
|
|
315
|
+
}
|
|
316
|
+
function hasDecision(evidence, caseId) {
|
|
317
|
+
return evidence.some((event) => event.payload.type === 'mcp.decision' && event.payload.caseId === caseId && event.payload.authority === 'scenario-host');
|
|
318
|
+
}
|
|
319
|
+
async function withTimeout(promise, timeoutMs, label) {
|
|
320
|
+
let timer;
|
|
321
|
+
return Promise.race([
|
|
322
|
+
promise,
|
|
323
|
+
new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); }),
|
|
324
|
+
]).finally(() => { if (timer)
|
|
325
|
+
clearTimeout(timer); });
|
|
326
|
+
}
|