@wix/pathgrade 1.0.24 → 1.0.26
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 +42 -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 +83 -46
- 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/cursor-scenario.d.ts +20 -0
- package/dist/agents/cursor-scenario.js +96 -0
- package/dist/agents/cursor.js +20 -0
- package/dist/agents/opencode/scenario.d.ts +25 -0
- package/dist/agents/opencode/scenario.js +195 -0
- package/dist/agents/opencode.js +50 -0
- 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/sdk/agent.js +57 -2
- package/dist/sdk/evaluate.js +6 -3
- package/dist/sdk/index.d.ts +4 -0
- package/dist/sdk/index.js +2 -0
- 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 +9 -1
- package/dist/types.d.ts +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createSdkMcpServer, query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { scanBearerSurfaces, TrialExecutionError } from './cleanup.js';
|
|
4
|
+
import { PrivateReplayVault, replayPrivateEvidence } from './evidence.js';
|
|
5
|
+
import { ScenarioHostReducer } from './host-reducer.js';
|
|
6
|
+
import { materializeProjection } from './materialize.js';
|
|
7
|
+
import { projectEndpoints } from './projection.js';
|
|
8
|
+
import { admitRuntimeLock } from './runtime-lock.js';
|
|
9
|
+
import { startScenarioHttpHost } from './scenario-http-host.js';
|
|
10
|
+
import { createClaudeParentBridges } from './claude-direct-mcp.js';
|
|
11
|
+
import { CLAUDE_DIRECT_MCP_DARWIN_ARM64_LOCK, CLAUDE_DIRECT_MCP_NORMALIZED_HANDSHAKE, resolveClaudeScenarioExecutable } from './claude-profile.js';
|
|
12
|
+
const defaultCoordinatorDependencies = {
|
|
13
|
+
startHost: startScenarioHttpHost,
|
|
14
|
+
scanBearerSurfaces,
|
|
15
|
+
};
|
|
16
|
+
export async function startScenarioCoordinator(options, dependencies = {}) {
|
|
17
|
+
const deps = { ...defaultCoordinatorDependencies, ...dependencies };
|
|
18
|
+
const scanRoots = scenarioScanRoots(options.workspacePath, options.environment);
|
|
19
|
+
const macKey = randomBytes(32);
|
|
20
|
+
const replayVault = new PrivateReplayVault(randomBytes(32));
|
|
21
|
+
const reducer = new ScenarioHostReducer(`${options.provider}-${randomBytes(12).toString('hex')}`, options.artifact, macKey, replayVault);
|
|
22
|
+
reducer.markCompiled();
|
|
23
|
+
let host;
|
|
24
|
+
let providerAttachment;
|
|
25
|
+
let activeTurn;
|
|
26
|
+
let removeAbortListener;
|
|
27
|
+
let cancellationRequest;
|
|
28
|
+
let cancellation;
|
|
29
|
+
let cancellationError;
|
|
30
|
+
let disposePromise;
|
|
31
|
+
const publishEvidence = () => {
|
|
32
|
+
options.onEvidence?.(structuredClone(reducer.ledger.events()));
|
|
33
|
+
};
|
|
34
|
+
const assertReplay = () => {
|
|
35
|
+
const replay = replayPrivateEvidence({
|
|
36
|
+
artifact: options.artifact,
|
|
37
|
+
evidence: reducer.ledger.events(),
|
|
38
|
+
vault: replayVault,
|
|
39
|
+
macKey,
|
|
40
|
+
});
|
|
41
|
+
if (!replay.ok || replay.finalState !== reducer.state || replay.poisoned) {
|
|
42
|
+
throw new Error(`Private scenario replay failed: ${!replay.ok ? replay.reason : replay.poisoned ? 'scenario poisoned' : 'state mismatch'}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const fence = (reason) => {
|
|
46
|
+
const turn = activeTurn;
|
|
47
|
+
if (!turn)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (reducer.phase === 'ACTIVE')
|
|
50
|
+
reducer.fence();
|
|
51
|
+
if (reducer.phase === 'FENCING')
|
|
52
|
+
reducer.abortUncommitted(reason);
|
|
53
|
+
return turn;
|
|
54
|
+
};
|
|
55
|
+
const requestCancellation = (reason, status) => {
|
|
56
|
+
if (cancellationRequest)
|
|
57
|
+
return;
|
|
58
|
+
const turn = fence(reason);
|
|
59
|
+
if (!turn)
|
|
60
|
+
return;
|
|
61
|
+
cancellationRequest = {
|
|
62
|
+
turn,
|
|
63
|
+
reason,
|
|
64
|
+
status,
|
|
65
|
+
interruptFailures: (async () => {
|
|
66
|
+
try {
|
|
67
|
+
await providerAttachment?.interrupt(turn, reason);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return [error];
|
|
72
|
+
}
|
|
73
|
+
})(),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const finishCancellation = (reason, status) => {
|
|
77
|
+
requestCancellation(reason, status);
|
|
78
|
+
if (cancellation)
|
|
79
|
+
return cancellation;
|
|
80
|
+
const requested = cancellationRequest;
|
|
81
|
+
if (!requested)
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
cancellation = (async () => {
|
|
84
|
+
const failures = await requested.interruptFailures;
|
|
85
|
+
try {
|
|
86
|
+
await providerAttachment?.waitForTerminal(requested.turn);
|
|
87
|
+
reducer.observeProviderTerminal();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
failures.push(error);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await host?.waitForQuiescence(30_000);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
failures.push(error);
|
|
97
|
+
}
|
|
98
|
+
if (failures.length > 0)
|
|
99
|
+
throw new AggregateError(failures, 'Scenario turn settlement failed');
|
|
100
|
+
reducer.finalize(requested.status, requested.status === 'poisoned' ? requested.reason : undefined);
|
|
101
|
+
assertReplay();
|
|
102
|
+
publishEvidence();
|
|
103
|
+
activeTurn = undefined;
|
|
104
|
+
})().catch((error) => {
|
|
105
|
+
cancellationError = error;
|
|
106
|
+
publishEvidence();
|
|
107
|
+
throw error;
|
|
108
|
+
});
|
|
109
|
+
void cancellation.catch(() => undefined);
|
|
110
|
+
return cancellation;
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
host = await deps.startHost({ artifact: options.artifact, reducer, secretRef: 'public-scenario-v2-bearer' });
|
|
114
|
+
providerAttachment = await options.attach({
|
|
115
|
+
artifact: options.artifact,
|
|
116
|
+
endpoints: host.endpoints,
|
|
117
|
+
resolveSecret(secretRef) {
|
|
118
|
+
if (secretRef !== host?.secretRef)
|
|
119
|
+
throw new Error(`Unknown secret reference: ${secretRef}`);
|
|
120
|
+
return `Bearer ${host.bearer.toString()}`;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
reducer.markAdmitted();
|
|
124
|
+
reducer.markReady();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const cleanupErrors = [];
|
|
128
|
+
const bearer = host?.bearer.toString() ?? '';
|
|
129
|
+
try {
|
|
130
|
+
await providerAttachment?.close();
|
|
131
|
+
}
|
|
132
|
+
catch (cleanupError) {
|
|
133
|
+
cleanupErrors.push(cleanupError);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await host?.dispose();
|
|
137
|
+
}
|
|
138
|
+
catch (cleanupError) {
|
|
139
|
+
cleanupErrors.push(cleanupError);
|
|
140
|
+
}
|
|
141
|
+
if (bearer.length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
const scans = await deps.scanBearerSurfaces(scanRoots, bearer);
|
|
144
|
+
if (scans.some((entry) => entry.status !== 'clean')) {
|
|
145
|
+
cleanupErrors.push(new Error('Scenario bearer reached or could not be excluded from a writable surface'));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (cleanupError) {
|
|
149
|
+
cleanupErrors.push(cleanupError);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
host?.bearer.fill(0);
|
|
154
|
+
macKey.fill(0);
|
|
155
|
+
replayVault.dispose();
|
|
156
|
+
reducer.dispose();
|
|
157
|
+
}
|
|
158
|
+
catch (cleanupError) {
|
|
159
|
+
cleanupErrors.push(cleanupError);
|
|
160
|
+
}
|
|
161
|
+
if (cleanupErrors.length > 0) {
|
|
162
|
+
throw new TrialExecutionError('Scenario coordinator setup failed and cleanup also failed', {
|
|
163
|
+
primary: error,
|
|
164
|
+
secondary: cleanupErrors,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
attachment: providerAttachment.value,
|
|
171
|
+
beginTurn(turnId, signal) {
|
|
172
|
+
if (activeTurn)
|
|
173
|
+
throw new Error('A scenario coordinator turn is already active');
|
|
174
|
+
const turn = { turnId, provider: options.provider };
|
|
175
|
+
reducer.beginTurn(turnId, options.provider);
|
|
176
|
+
activeTurn = turn;
|
|
177
|
+
cancellationRequest = undefined;
|
|
178
|
+
cancellation = undefined;
|
|
179
|
+
cancellationError = undefined;
|
|
180
|
+
const onAbort = () => { requestCancellation('provider-cancelled', 'cancelled-clean'); };
|
|
181
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
182
|
+
removeAbortListener = () => signal.removeEventListener('abort', onAbort);
|
|
183
|
+
if (signal.aborted)
|
|
184
|
+
onAbort();
|
|
185
|
+
},
|
|
186
|
+
async settleTurn() {
|
|
187
|
+
removeAbortListener?.();
|
|
188
|
+
removeAbortListener = undefined;
|
|
189
|
+
try {
|
|
190
|
+
if (cancellationRequest)
|
|
191
|
+
return await finishCancellation('provider-cancelled', 'cancelled-clean');
|
|
192
|
+
const turn = activeTurn;
|
|
193
|
+
if (!turn)
|
|
194
|
+
throw new Error('No scenario coordinator turn is active');
|
|
195
|
+
await providerAttachment.waitForTerminal(turn);
|
|
196
|
+
reducer.observeProviderTerminal();
|
|
197
|
+
await host?.waitForQuiescence(30_000);
|
|
198
|
+
if (reducer.phase === 'ACTIVE')
|
|
199
|
+
reducer.finalize('settled');
|
|
200
|
+
else if (reducer.phase === 'POISONED')
|
|
201
|
+
reducer.finalize('poisoned', 'scenario-host-poisoned');
|
|
202
|
+
else
|
|
203
|
+
await finishCancellation('provider-cancelled', 'cancelled-clean');
|
|
204
|
+
assertReplay();
|
|
205
|
+
publishEvidence();
|
|
206
|
+
activeTurn = undefined;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
publishEvidence();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
async failTurn(error) {
|
|
214
|
+
removeAbortListener?.();
|
|
215
|
+
removeAbortListener = undefined;
|
|
216
|
+
try {
|
|
217
|
+
await finishCancellation('provider-failed', 'poisoned');
|
|
218
|
+
}
|
|
219
|
+
catch (lifecycleError) {
|
|
220
|
+
throw new TrialExecutionError('Provider turn failed and scenario settlement also failed', {
|
|
221
|
+
primary: error,
|
|
222
|
+
secondary: lifecycleError instanceof AggregateError
|
|
223
|
+
? lifecycleError.errors
|
|
224
|
+
: [lifecycleError],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
evidence: () => structuredClone(reducer.ledger.events()),
|
|
229
|
+
dispose() {
|
|
230
|
+
disposePromise ??= (async () => {
|
|
231
|
+
const failures = [];
|
|
232
|
+
const bearer = host?.bearer.toString() ?? '';
|
|
233
|
+
try {
|
|
234
|
+
if (activeTurn)
|
|
235
|
+
await finishCancellation('disposed', 'cancelled-clean');
|
|
236
|
+
else if (cancellationError !== undefined)
|
|
237
|
+
failures.push(cancellationError);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
failures.push(error);
|
|
241
|
+
}
|
|
242
|
+
publishEvidence();
|
|
243
|
+
try {
|
|
244
|
+
await providerAttachment?.close();
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
failures.push(error);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await host?.dispose();
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
failures.push(error);
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const scans = await deps.scanBearerSurfaces(scanRoots, bearer);
|
|
257
|
+
if (scans.some((entry) => entry.status !== 'clean')) {
|
|
258
|
+
failures.push(new Error('Scenario bearer reached or could not be excluded from a writable surface'));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
failures.push(error);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
host?.bearer.fill(0);
|
|
266
|
+
macKey.fill(0);
|
|
267
|
+
replayVault.dispose();
|
|
268
|
+
reducer.dispose();
|
|
269
|
+
publishEvidence();
|
|
270
|
+
}
|
|
271
|
+
if (failures.length > 0)
|
|
272
|
+
throw new AggregateError(failures, 'Scenario coordinator cleanup failed');
|
|
273
|
+
})();
|
|
274
|
+
return disposePromise;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function scenarioScanRoots(workspacePath, environment) {
|
|
279
|
+
return [...new Set([
|
|
280
|
+
workspacePath,
|
|
281
|
+
environment.HOME,
|
|
282
|
+
environment.TMPDIR,
|
|
283
|
+
environment.TMP,
|
|
284
|
+
environment.TEMP,
|
|
285
|
+
environment.CLAUDE_CONFIG_DIR,
|
|
286
|
+
].filter((path) => typeof path === 'string' && path.length > 0))];
|
|
287
|
+
}
|
|
288
|
+
export async function startPublicClaudeScenarioRuntime(options, dependencies = {}) {
|
|
289
|
+
const lock = dependencies.lock ?? CLAUDE_DIRECT_MCP_DARWIN_ARM64_LOCK;
|
|
290
|
+
const resolveExecutable = dependencies.resolveExecutable ?? resolveClaudeScenarioExecutable;
|
|
291
|
+
const coordinator = await startScenarioCoordinator({
|
|
292
|
+
...options,
|
|
293
|
+
provider: 'claude',
|
|
294
|
+
async attach(context) {
|
|
295
|
+
const bearer = context.resolveSecret('public-scenario-v2-bearer');
|
|
296
|
+
const admission = await admitRuntimeLock(lock, {
|
|
297
|
+
provider: 'claude',
|
|
298
|
+
platform: process.platform,
|
|
299
|
+
architecture: process.arch,
|
|
300
|
+
resolvedExecutable: await resolveExecutable(options.environment),
|
|
301
|
+
environment: options.environment,
|
|
302
|
+
forbiddenSecrets: [bearer.slice('Bearer '.length), bearer],
|
|
303
|
+
...(dependencies.commandVersion ? { commandVersion: dependencies.commandVersion } : {}),
|
|
304
|
+
initializeControlPlane: async () => {
|
|
305
|
+
if (typeof createSdkMcpServer !== 'function' || typeof sdkQuery !== 'function') {
|
|
306
|
+
throw new Error('Claude SDK parent bridge methods unavailable');
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
normalizedHandshake: CLAUDE_DIRECT_MCP_NORMALIZED_HANDSHAKE,
|
|
310
|
+
capabilities: lock.capabilities,
|
|
311
|
+
close: async () => undefined,
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const projected = projectEndpoints(context.endpoints, admission.profile);
|
|
316
|
+
if (!Array.isArray(projected))
|
|
317
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: ${projected.reason}`);
|
|
318
|
+
const materialized = projected.map((plan) => materializeProjection(plan, {
|
|
319
|
+
resolve: context.resolveSecret,
|
|
320
|
+
}));
|
|
321
|
+
const bridges = createClaudeParentBridges({ artifact: options.artifact, projections: materialized });
|
|
322
|
+
return {
|
|
323
|
+
value: bridges,
|
|
324
|
+
async interrupt() { },
|
|
325
|
+
async waitForTerminal() { },
|
|
326
|
+
async close() {
|
|
327
|
+
const failures = [];
|
|
328
|
+
for (const bridge of Object.values(bridges)) {
|
|
329
|
+
try {
|
|
330
|
+
await bridge.instance.close();
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
failures.push(error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (failures.length > 0)
|
|
337
|
+
throw new AggregateError(failures, 'Claude scenario bridges failed to close');
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return {
|
|
343
|
+
claudeServers: coordinator.attachment,
|
|
344
|
+
configureSession(sessionOptions) {
|
|
345
|
+
sessionOptions.scenarioMcpServers = coordinator.attachment;
|
|
346
|
+
},
|
|
347
|
+
beginTurn: coordinator.beginTurn,
|
|
348
|
+
settleTurn: coordinator.settleTurn,
|
|
349
|
+
failTurn: coordinator.failTurn,
|
|
350
|
+
evidence: coordinator.evidence,
|
|
351
|
+
dispose: coordinator.dispose,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AdmittedRuntimeProfile, JsonObject, RuntimeCapabilityProfile } from './types.js';
|
|
2
|
+
export type RuntimeLock = {
|
|
3
|
+
format: 'pathgrade-direct-mcp-runtime-lock-v1';
|
|
4
|
+
provider: 'claude' | 'codex' | 'cursor' | 'opencode';
|
|
5
|
+
platform: string;
|
|
6
|
+
architecture: string;
|
|
7
|
+
controlPlane: 'native-sdk-parent-bridge' | 'app-server' | 'acp-v1';
|
|
8
|
+
version: string;
|
|
9
|
+
commandArtifact: string;
|
|
10
|
+
args: readonly string[];
|
|
11
|
+
versionArgs: readonly string[];
|
|
12
|
+
artifacts: readonly {
|
|
13
|
+
id: string;
|
|
14
|
+
relativePath: string;
|
|
15
|
+
sha256: string;
|
|
16
|
+
}[];
|
|
17
|
+
capabilities: RuntimeCapabilityProfile['requiredCapabilities'];
|
|
18
|
+
normalizedHandshakeSha256: string;
|
|
19
|
+
};
|
|
20
|
+
export type RuntimeLockControlPlane = {
|
|
21
|
+
normalizedHandshake: JsonObject;
|
|
22
|
+
capabilities: RuntimeCapabilityProfile['requiredCapabilities'];
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
export type RuntimeLockAdmissionContext<TControlPlane extends RuntimeLockControlPlane> = {
|
|
26
|
+
provider: RuntimeLock['provider'];
|
|
27
|
+
platform: string;
|
|
28
|
+
architecture: string;
|
|
29
|
+
resolvedExecutable: string;
|
|
30
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
31
|
+
forbiddenSecrets?: readonly string[];
|
|
32
|
+
commandVersion?: (commandPath: string, args: readonly string[]) => Promise<string>;
|
|
33
|
+
initializeControlPlane(commandPath: string, args: readonly string[]): Promise<TControlPlane>;
|
|
34
|
+
};
|
|
35
|
+
export type RuntimeLockIdentity = {
|
|
36
|
+
provider: RuntimeLock['provider'];
|
|
37
|
+
platform: string;
|
|
38
|
+
architecture: string;
|
|
39
|
+
version: string;
|
|
40
|
+
artifactHashes: Readonly<Record<string, string>>;
|
|
41
|
+
capabilities: RuntimeCapabilityProfile['requiredCapabilities'];
|
|
42
|
+
normalizedHandshakeSha256: string;
|
|
43
|
+
lockFingerprint: string;
|
|
44
|
+
};
|
|
45
|
+
export type RuntimeLockAdmission<TControlPlane extends RuntimeLockControlPlane = RuntimeLockControlPlane> = {
|
|
46
|
+
commandPath: string;
|
|
47
|
+
identity: RuntimeLockIdentity;
|
|
48
|
+
profile: AdmittedRuntimeProfile;
|
|
49
|
+
controlPlane: TControlPlane;
|
|
50
|
+
};
|
|
51
|
+
export declare function admitRuntimeLock<TControlPlane extends RuntimeLockControlPlane>(rawLock: RuntimeLock, context: RuntimeLockAdmissionContext<TControlPlane>): Promise<RuntimeLockAdmission<TControlPlane>>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters -- Runtime observations cross the provider process trust boundary. */
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { canonicalJson, sha256Canonical } from './json.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
10
|
+
export async function admitRuntimeLock(rawLock, context) {
|
|
11
|
+
let controlPlane;
|
|
12
|
+
try {
|
|
13
|
+
const lock = validateRuntimeLock(rawLock);
|
|
14
|
+
if (lock.provider !== context.provider)
|
|
15
|
+
throw new Error('provider mismatch');
|
|
16
|
+
if (lock.platform !== context.platform || lock.architecture !== context.architecture)
|
|
17
|
+
throw new Error('platform or architecture mismatch');
|
|
18
|
+
const commandArtifact = lock.artifacts.find((artifact) => artifact.id === lock.commandArtifact);
|
|
19
|
+
if (!commandArtifact)
|
|
20
|
+
throw new Error('command artifact missing');
|
|
21
|
+
const commandPath = await realpath(context.resolvedExecutable);
|
|
22
|
+
const installationRoot = installationRootForCommand(commandPath, commandArtifact.relativePath);
|
|
23
|
+
const artifactPaths = new Map();
|
|
24
|
+
const artifactIdentities = new Map();
|
|
25
|
+
const artifactHashes = {};
|
|
26
|
+
for (const artifact of lock.artifacts) {
|
|
27
|
+
const path = resolveArtifactPath(installationRoot, artifact.relativePath);
|
|
28
|
+
const identity = await verifyRegularResolvedFile(path);
|
|
29
|
+
const digest = createHash('sha256').update(await readFile(path)).digest('hex');
|
|
30
|
+
if (digest !== artifact.sha256)
|
|
31
|
+
throw new Error(`artifact digest mismatch: ${artifact.id}`);
|
|
32
|
+
artifactPaths.set(artifact.id, path);
|
|
33
|
+
artifactIdentities.set(artifact.id, identity);
|
|
34
|
+
artifactHashes[artifact.id] = digest;
|
|
35
|
+
}
|
|
36
|
+
if (artifactPaths.get(lock.commandArtifact) !== commandPath)
|
|
37
|
+
throw new Error('resolved executable does not match command artifact');
|
|
38
|
+
const forbidden = forbiddenSurface([...lock.args, ...lock.versionArgs], context.environment ?? {}, context.forbiddenSecrets ?? []);
|
|
39
|
+
if (forbidden)
|
|
40
|
+
throw new Error(forbidden);
|
|
41
|
+
await verifyArtifactsUnchanged(lock, artifactPaths, artifactIdentities);
|
|
42
|
+
const version = context.commandVersion
|
|
43
|
+
? await context.commandVersion(commandPath, lock.versionArgs)
|
|
44
|
+
: `${(await execFileAsync(commandPath, [...lock.versionArgs], {
|
|
45
|
+
timeout: 15_000,
|
|
46
|
+
maxBuffer: 1024 * 1024,
|
|
47
|
+
env: versionProbeEnvironment(context.environment ?? process.env),
|
|
48
|
+
})).stdout}`.trim();
|
|
49
|
+
if (version !== lock.version)
|
|
50
|
+
throw new Error('version mismatch');
|
|
51
|
+
await verifyArtifactsUnchanged(lock, artifactPaths, artifactIdentities);
|
|
52
|
+
controlPlane = await context.initializeControlPlane(commandPath, lock.args);
|
|
53
|
+
if (canonicalJson(controlPlane.capabilities) !== canonicalJson(lock.capabilities))
|
|
54
|
+
throw new Error('capability mismatch');
|
|
55
|
+
if (sha256Canonical(controlPlane.normalizedHandshake) !== lock.normalizedHandshakeSha256)
|
|
56
|
+
throw new Error('normalized handshake mismatch');
|
|
57
|
+
await verifyArtifactsUnchanged(lock, artifactPaths, artifactIdentities);
|
|
58
|
+
const lockFingerprint = sha256Canonical(lock);
|
|
59
|
+
const profile = {
|
|
60
|
+
format: 'pathgrade-direct-mcp-runtime-profile-v1',
|
|
61
|
+
provider: lock.provider,
|
|
62
|
+
platform: lock.platform,
|
|
63
|
+
architecture: lock.architecture,
|
|
64
|
+
controlPlane: lock.controlPlane,
|
|
65
|
+
command: { absolutePath: commandPath, args: [...lock.args], versionArgs: [...lock.versionArgs] },
|
|
66
|
+
expectedVersion: lock.version,
|
|
67
|
+
artifacts: lock.artifacts.map((artifact) => ({
|
|
68
|
+
label: artifact.id,
|
|
69
|
+
absolutePath: artifactPaths.get(artifact.id),
|
|
70
|
+
sha256: artifact.sha256,
|
|
71
|
+
})),
|
|
72
|
+
requiredCapabilities: lock.capabilities,
|
|
73
|
+
profileFingerprint: lockFingerprint,
|
|
74
|
+
runtimeCapabilityFingerprint: lock.normalizedHandshakeSha256,
|
|
75
|
+
admitted: true,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
commandPath,
|
|
79
|
+
identity: {
|
|
80
|
+
provider: lock.provider,
|
|
81
|
+
platform: lock.platform,
|
|
82
|
+
architecture: lock.architecture,
|
|
83
|
+
version: lock.version,
|
|
84
|
+
artifactHashes,
|
|
85
|
+
capabilities: lock.capabilities,
|
|
86
|
+
normalizedHandshakeSha256: lock.normalizedHandshakeSha256,
|
|
87
|
+
lockFingerprint,
|
|
88
|
+
},
|
|
89
|
+
profile,
|
|
90
|
+
controlPlane,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
await controlPlane?.close().catch(() => undefined);
|
|
95
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function validateRuntimeLock(lock) {
|
|
99
|
+
canonicalJson(lock);
|
|
100
|
+
exactKeys(lock, [
|
|
101
|
+
'format', 'provider', 'platform', 'architecture', 'controlPlane', 'version',
|
|
102
|
+
'commandArtifact', 'args', 'versionArgs', 'artifacts', 'capabilities',
|
|
103
|
+
'normalizedHandshakeSha256',
|
|
104
|
+
], '$');
|
|
105
|
+
if (lock.format !== 'pathgrade-direct-mcp-runtime-lock-v1')
|
|
106
|
+
throw new Error('unsupported runtime lock');
|
|
107
|
+
if (lock.provider !== 'claude' && lock.provider !== 'codex' && lock.provider !== 'cursor' && lock.provider !== 'opencode')
|
|
108
|
+
throw new Error('unsupported runtime-lock provider');
|
|
109
|
+
if ((lock.provider === 'claude' && lock.controlPlane !== 'native-sdk-parent-bridge')
|
|
110
|
+
|| (lock.provider === 'codex' && lock.controlPlane !== 'app-server')
|
|
111
|
+
|| ((lock.provider === 'cursor' || lock.provider === 'opencode') && lock.controlPlane !== 'acp-v1'))
|
|
112
|
+
throw new Error('provider/control-plane mismatch');
|
|
113
|
+
if (!lock.platform || !lock.architecture || !lock.version)
|
|
114
|
+
throw new Error('runtime-lock identity is incomplete');
|
|
115
|
+
if (!Array.isArray(lock.args) || lock.args.some((entry) => typeof entry !== 'string')
|
|
116
|
+
|| !Array.isArray(lock.versionArgs) || lock.versionArgs.some((entry) => typeof entry !== 'string'))
|
|
117
|
+
throw new Error('runtime-lock arguments are invalid');
|
|
118
|
+
if (!Array.isArray(lock.artifacts) || lock.artifacts.length === 0)
|
|
119
|
+
throw new Error('runtime-lock artifacts are empty');
|
|
120
|
+
const ids = new Set();
|
|
121
|
+
for (const artifact of lock.artifacts) {
|
|
122
|
+
exactKeys(artifact, ['id', 'relativePath', 'sha256'], `artifact:${artifact.id || '<missing>'}`);
|
|
123
|
+
if (!artifact.id || ids.has(artifact.id))
|
|
124
|
+
throw new Error('runtime-lock artifact IDs must be unique');
|
|
125
|
+
ids.add(artifact.id);
|
|
126
|
+
validateRelativeArtifactPath(artifact.relativePath);
|
|
127
|
+
if (!SHA256.test(artifact.sha256))
|
|
128
|
+
throw new Error(`invalid artifact digest: ${artifact.id}`);
|
|
129
|
+
}
|
|
130
|
+
exactKeys(lock.capabilities, ['streamableHttp', 'controlPlaneHeaders', 'parentOwnedSdkMcp', 'cancellation'], '$/capabilities');
|
|
131
|
+
if (lock.capabilities.streamableHttp !== true || lock.capabilities.controlPlaneHeaders !== true)
|
|
132
|
+
throw new Error('required capabilities are incomplete');
|
|
133
|
+
const expectedCancellation = lock.provider === 'claude' ? 'abort-controller' : lock.provider === 'codex' ? 'turn-interrupt' : 'session-cancel';
|
|
134
|
+
if (lock.capabilities.cancellation !== expectedCancellation)
|
|
135
|
+
throw new Error('provider/cancellation mismatch');
|
|
136
|
+
if ((lock.provider === 'claude') !== (lock.capabilities.parentOwnedSdkMcp === true))
|
|
137
|
+
throw new Error('provider/parent-owned SDK bridge mismatch');
|
|
138
|
+
if (!SHA256.test(lock.normalizedHandshakeSha256))
|
|
139
|
+
throw new Error('invalid normalized handshake digest');
|
|
140
|
+
return lock;
|
|
141
|
+
}
|
|
142
|
+
function exactKeys(value, allowed, path) {
|
|
143
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
144
|
+
if (unknown.length > 0)
|
|
145
|
+
throw new Error(`${path}: unknown runtime-lock fields ${unknown.sort().join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
function validateRelativeArtifactPath(path) {
|
|
148
|
+
if (!path || isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
149
|
+
throw new Error(`unsafe artifact-relative path: ${path}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function installationRootForCommand(commandPath, relativeCommandPath) {
|
|
153
|
+
validateRelativeArtifactPath(relativeCommandPath);
|
|
154
|
+
let root = commandPath;
|
|
155
|
+
for (const _part of relativeCommandPath.split('/'))
|
|
156
|
+
root = dirname(root);
|
|
157
|
+
if (resolveArtifactPath(root, relativeCommandPath) !== commandPath)
|
|
158
|
+
throw new Error('resolved executable escaped its runtime root');
|
|
159
|
+
return root;
|
|
160
|
+
}
|
|
161
|
+
function resolveArtifactPath(root, relativePath) {
|
|
162
|
+
validateRelativeArtifactPath(relativePath);
|
|
163
|
+
const path = resolve(root, ...relativePath.split('/'));
|
|
164
|
+
const difference = relative(root, path);
|
|
165
|
+
if (difference === '' || difference.startsWith('..') || isAbsolute(difference))
|
|
166
|
+
throw new Error(`artifact escaped runtime root: ${relativePath}`);
|
|
167
|
+
return path;
|
|
168
|
+
}
|
|
169
|
+
async function verifyRegularResolvedFile(path) {
|
|
170
|
+
const stat = await lstat(path, { bigint: true });
|
|
171
|
+
if (!stat.isFile() || stat.isSymbolicLink() || await realpath(path) !== path)
|
|
172
|
+
throw new Error(`artifact is not a regular resolved file: ${path}`);
|
|
173
|
+
return { device: stat.dev, inode: stat.ino, size: stat.size, modifiedNs: stat.mtimeNs };
|
|
174
|
+
}
|
|
175
|
+
async function verifyUnchanged(path, before) {
|
|
176
|
+
const after = await verifyRegularResolvedFile(path);
|
|
177
|
+
if (after.device !== before.device || after.inode !== before.inode || after.size !== before.size || after.modifiedNs !== before.modifiedNs) {
|
|
178
|
+
throw new Error(`artifact changed during admission: ${path}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function verifyArtifactsUnchanged(lock, paths, identities) {
|
|
182
|
+
for (const artifact of lock.artifacts) {
|
|
183
|
+
await verifyUnchanged(paths.get(artifact.id), identities.get(artifact.id));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function versionProbeEnvironment(environment) {
|
|
187
|
+
const allowed = ['HOME', 'PATH', 'PATHEXT', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'WINDIR'];
|
|
188
|
+
return Object.fromEntries(allowed.flatMap((name) => {
|
|
189
|
+
const value = environment[name];
|
|
190
|
+
return value === undefined ? [] : [[name, value]];
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
function forbiddenSurface(arguments_, environment, secrets) {
|
|
194
|
+
for (const argument of arguments_) {
|
|
195
|
+
if (secrets.some((secret) => secret.length > 0 && argument.includes(secret)))
|
|
196
|
+
return 'protected value reached runtime arguments';
|
|
197
|
+
}
|
|
198
|
+
for (const [name, value] of Object.entries(environment)) {
|
|
199
|
+
if (value !== undefined && secrets.some((secret) => secret.length > 0 && value.includes(secret)))
|
|
200
|
+
return `protected value reached runtime environment entry: ${name}`;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AdmittedRuntimeProfile, JsonObject, RuntimeCapabilityProfile } from './types.js';
|
|
2
|
+
export type RuntimeAdmissionContext = {
|
|
3
|
+
provider: RuntimeCapabilityProfile['provider'];
|
|
4
|
+
platform: string;
|
|
5
|
+
architecture: string;
|
|
6
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
7
|
+
forbiddenSecrets?: readonly string[];
|
|
8
|
+
commandVersion?: (path: string, args: readonly string[]) => Promise<string>;
|
|
9
|
+
initializeControlPlane: () => Promise<{
|
|
10
|
+
normalizedHandshake: JsonObject;
|
|
11
|
+
capabilities: RuntimeCapabilityProfile['requiredCapabilities'];
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
export type RuntimeAdmissionResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
profile: AdmittedRuntimeProfile;
|
|
18
|
+
normalizedHandshake: JsonObject;
|
|
19
|
+
controlPlane: Awaited<ReturnType<RuntimeAdmissionContext['initializeControlPlane']>>;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
code: 'RUNTIME_NOT_ADMITTED';
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function profileFingerprint(profile: Omit<RuntimeCapabilityProfile, 'profileFingerprint' | 'runtimeCapabilityFingerprint'>): string;
|
|
26
|
+
export declare function runtimeCapabilityFingerprint(normalizedHandshake: JsonObject): string;
|
|
27
|
+
export declare function parseRuntimeProfile(input: string | unknown): RuntimeCapabilityProfile;
|
|
28
|
+
export declare function admitRuntimeProfile(rawProfile: string | unknown, context: RuntimeAdmissionContext): Promise<RuntimeAdmissionResult>;
|