@wix/pathgrade 1.0.25 → 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 +49 -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,163 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { canonicalJson, parseStrictJson, sha256Canonical } from './json.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
9
|
+
const PROVIDERS = ['claude', 'codex', 'cursor', 'opencode'];
|
|
10
|
+
const CONTROL_PLANES = ['native-sdk-parent-bridge', 'app-server', 'acp-v1'];
|
|
11
|
+
const CANCELLATIONS = ['abort-controller', 'turn-interrupt', 'session-cancel'];
|
|
12
|
+
function object(value) {
|
|
13
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function exactKeys(value, allowed, path) {
|
|
16
|
+
const extra = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
17
|
+
if (extra.length > 0)
|
|
18
|
+
throw new TypeError(`${path}: unknown fields ${extra.sort().join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
function stringArray(value, path) {
|
|
21
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))
|
|
22
|
+
throw new TypeError(`${path}: expected string array`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function profileFingerprint(profile) {
|
|
26
|
+
return sha256Canonical(profile);
|
|
27
|
+
}
|
|
28
|
+
export function runtimeCapabilityFingerprint(normalizedHandshake) {
|
|
29
|
+
return sha256Canonical(normalizedHandshake);
|
|
30
|
+
}
|
|
31
|
+
export function parseRuntimeProfile(input) {
|
|
32
|
+
const value = typeof input === 'string' ? parseStrictJson(input) : input;
|
|
33
|
+
canonicalJson(value);
|
|
34
|
+
if (!object(value))
|
|
35
|
+
throw new TypeError('$: runtime profile must be an object');
|
|
36
|
+
exactKeys(value, ['format', 'provider', 'platform', 'architecture', 'controlPlane', 'command', 'expectedVersion', 'artifacts', 'requiredCapabilities', 'profileFingerprint', 'runtimeCapabilityFingerprint'], '$');
|
|
37
|
+
if (value.format !== 'pathgrade-direct-mcp-runtime-profile-v1')
|
|
38
|
+
throw new TypeError('$/format: unsupported profile format');
|
|
39
|
+
if (!PROVIDERS.includes(value.provider))
|
|
40
|
+
throw new TypeError('$/provider: unsupported provider');
|
|
41
|
+
if (typeof value.platform !== 'string' || typeof value.architecture !== 'string')
|
|
42
|
+
throw new TypeError('$: platform and architecture must be strings');
|
|
43
|
+
if (!CONTROL_PLANES.includes(value.controlPlane))
|
|
44
|
+
throw new TypeError('$/controlPlane: unsupported control plane');
|
|
45
|
+
if (!object(value.command))
|
|
46
|
+
throw new TypeError('$/command: expected object');
|
|
47
|
+
exactKeys(value.command, ['absolutePath', 'args', 'versionArgs'], '$/command');
|
|
48
|
+
if (typeof value.command.absolutePath !== 'string')
|
|
49
|
+
throw new TypeError('$/command/absolutePath: expected string');
|
|
50
|
+
const args = stringArray(value.command.args, '$/command/args');
|
|
51
|
+
const versionArgs = stringArray(value.command.versionArgs, '$/command/versionArgs');
|
|
52
|
+
if (typeof value.expectedVersion !== 'string')
|
|
53
|
+
throw new TypeError('$/expectedVersion: expected string');
|
|
54
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length === 0)
|
|
55
|
+
throw new TypeError('$/artifacts: expected non-empty array');
|
|
56
|
+
const labels = new Set();
|
|
57
|
+
const artifacts = value.artifacts.map((entry, index) => {
|
|
58
|
+
if (!object(entry))
|
|
59
|
+
throw new TypeError(`$/artifacts/${index}: expected object`);
|
|
60
|
+
exactKeys(entry, ['label', 'absolutePath', 'sha256'], `$/artifacts/${index}`);
|
|
61
|
+
if (typeof entry.label !== 'string' || labels.has(entry.label))
|
|
62
|
+
throw new TypeError(`$/artifacts/${index}/label: expected unique string`);
|
|
63
|
+
labels.add(entry.label);
|
|
64
|
+
if (typeof entry.absolutePath !== 'string' || typeof entry.sha256 !== 'string' || !SHA256.test(entry.sha256))
|
|
65
|
+
throw new TypeError(`$/artifacts/${index}: invalid path or digest`);
|
|
66
|
+
return { label: entry.label, absolutePath: entry.absolutePath, sha256: entry.sha256 };
|
|
67
|
+
});
|
|
68
|
+
if (!object(value.requiredCapabilities))
|
|
69
|
+
throw new TypeError('$/requiredCapabilities: expected object');
|
|
70
|
+
exactKeys(value.requiredCapabilities, ['streamableHttp', 'controlPlaneHeaders', 'parentOwnedSdkMcp', 'cancellation'], '$/requiredCapabilities');
|
|
71
|
+
if (value.requiredCapabilities.streamableHttp !== true || value.requiredCapabilities.controlPlaneHeaders !== true || !CANCELLATIONS.includes(value.requiredCapabilities.cancellation)) {
|
|
72
|
+
throw new TypeError('$/requiredCapabilities: required MCP capabilities are invalid');
|
|
73
|
+
}
|
|
74
|
+
if (value.requiredCapabilities.parentOwnedSdkMcp !== undefined && value.requiredCapabilities.parentOwnedSdkMcp !== true)
|
|
75
|
+
throw new TypeError('$/requiredCapabilities/parentOwnedSdkMcp: must be true when present');
|
|
76
|
+
if (typeof value.profileFingerprint !== 'string' || !SHA256.test(value.profileFingerprint) || typeof value.runtimeCapabilityFingerprint !== 'string' || !SHA256.test(value.runtimeCapabilityFingerprint)) {
|
|
77
|
+
throw new TypeError('$: fingerprints must be lowercase SHA-256');
|
|
78
|
+
}
|
|
79
|
+
const parsed = {
|
|
80
|
+
...value,
|
|
81
|
+
provider: value.provider,
|
|
82
|
+
controlPlane: value.controlPlane,
|
|
83
|
+
command: { absolutePath: value.command.absolutePath, args, versionArgs },
|
|
84
|
+
artifacts,
|
|
85
|
+
requiredCapabilities: value.requiredCapabilities,
|
|
86
|
+
};
|
|
87
|
+
const { profileFingerprint: stored, runtimeCapabilityFingerprint: _runtime, ...fingerprinted } = parsed;
|
|
88
|
+
if (profileFingerprint(fingerprinted) !== stored)
|
|
89
|
+
throw new TypeError('$/profileFingerprint: fingerprint mismatch');
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
async function verifyRegularNoSymlink(path) {
|
|
93
|
+
if (!isAbsolute(path))
|
|
94
|
+
throw new Error(`path is not absolute: ${path}`);
|
|
95
|
+
const before = await lstat(path, { bigint: true });
|
|
96
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
97
|
+
throw new Error(`path is not a regular non-symlink file: ${path}`);
|
|
98
|
+
if (await realpath(path) !== path)
|
|
99
|
+
throw new Error(`path resolves through a symlink: ${path}`);
|
|
100
|
+
return { device: before.dev, inode: before.ino, size: before.size, modifiedNs: before.mtimeNs };
|
|
101
|
+
}
|
|
102
|
+
async function verifyUnchanged(path, identity) {
|
|
103
|
+
const after = await verifyRegularNoSymlink(path);
|
|
104
|
+
if (after.device !== identity.device || after.inode !== identity.inode || after.size !== identity.size || after.modifiedNs !== identity.modifiedNs) {
|
|
105
|
+
throw new Error(`file moved or changed during admission: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function fileSha256(path) {
|
|
109
|
+
return createHash('sha256').update(await readFile(path)).digest('hex');
|
|
110
|
+
}
|
|
111
|
+
function containsForbiddenSurface(values, environment, secrets) {
|
|
112
|
+
const unsafeName = /(authorization|bearer|api[_-]?key|secret|token)/i;
|
|
113
|
+
for (const value of values) {
|
|
114
|
+
if (unsafeName.test(value) || secrets.some((secret) => secret.length > 0 && value.includes(secret)))
|
|
115
|
+
return `forbidden command argument: ${value.replace(/=.*/, '=<redacted>')}`;
|
|
116
|
+
}
|
|
117
|
+
for (const [name, value] of Object.entries(environment)) {
|
|
118
|
+
if (value !== undefined && secrets.some((secret) => secret.length > 0 && value.includes(secret)))
|
|
119
|
+
return `forbidden environment entry: ${name}`;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function capabilitiesEqual(expected, actual) {
|
|
124
|
+
return canonicalJson(expected) === canonicalJson(actual);
|
|
125
|
+
}
|
|
126
|
+
export async function admitRuntimeProfile(rawProfile, context) {
|
|
127
|
+
let controlPlane;
|
|
128
|
+
try {
|
|
129
|
+
const profile = parseRuntimeProfile(rawProfile);
|
|
130
|
+
if (profile.provider !== context.provider)
|
|
131
|
+
throw new Error('provider/profile identity mismatch');
|
|
132
|
+
if (profile.platform !== context.platform || profile.architecture !== context.architecture)
|
|
133
|
+
throw new Error('platform or architecture mismatch');
|
|
134
|
+
const commandIdentity = await verifyRegularNoSymlink(profile.command.absolutePath);
|
|
135
|
+
const artifactIdentities = new Map();
|
|
136
|
+
for (const artifact of profile.artifacts) {
|
|
137
|
+
const identity = await verifyRegularNoSymlink(artifact.absolutePath);
|
|
138
|
+
artifactIdentities.set(artifact.absolutePath, identity);
|
|
139
|
+
if (await fileSha256(artifact.absolutePath) !== artifact.sha256)
|
|
140
|
+
throw new Error(`artifact digest mismatch: ${artifact.label}`);
|
|
141
|
+
await verifyUnchanged(artifact.absolutePath, identity);
|
|
142
|
+
}
|
|
143
|
+
await verifyUnchanged(profile.command.absolutePath, commandIdentity);
|
|
144
|
+
const version = context.commandVersion
|
|
145
|
+
? await context.commandVersion(profile.command.absolutePath, profile.command.versionArgs)
|
|
146
|
+
: `${(await execFileAsync(profile.command.absolutePath, [...profile.command.versionArgs], { timeout: 15_000, maxBuffer: 1024 * 1024 })).stdout}`.trim();
|
|
147
|
+
if (version !== profile.expectedVersion)
|
|
148
|
+
throw new Error('version mismatch');
|
|
149
|
+
const forbidden = containsForbiddenSurface([...profile.command.args, ...profile.command.versionArgs], context.environment ?? {}, context.forbiddenSecrets ?? []);
|
|
150
|
+
if (forbidden)
|
|
151
|
+
throw new Error(forbidden);
|
|
152
|
+
controlPlane = await context.initializeControlPlane();
|
|
153
|
+
if (!capabilitiesEqual(profile.requiredCapabilities, controlPlane.capabilities))
|
|
154
|
+
throw new Error('required capability mismatch');
|
|
155
|
+
if (runtimeCapabilityFingerprint(controlPlane.normalizedHandshake) !== profile.runtimeCapabilityFingerprint)
|
|
156
|
+
throw new Error('runtime capability fingerprint drift');
|
|
157
|
+
return { ok: true, profile: { ...profile, admitted: true }, normalizedHandshake: controlPlane.normalizedHandshake, controlPlane };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await controlPlane?.close().catch(() => undefined);
|
|
161
|
+
return { ok: false, code: 'RUNTIME_NOT_ADMITTED', reason: error instanceof Error ? error.message : String(error) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ScenarioHostReducer } from './host-reducer.js';
|
|
2
|
+
import type { EndpointSpec, ScenarioArtifact } from './types.js';
|
|
3
|
+
export type InvocationBarrier = {
|
|
4
|
+
caseId: string;
|
|
5
|
+
wait(): Promise<void>;
|
|
6
|
+
release(): void;
|
|
7
|
+
reached(): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type ScenarioHttpHost = {
|
|
10
|
+
endpoints: readonly EndpointSpec[];
|
|
11
|
+
secretRef: string;
|
|
12
|
+
bearer: Buffer;
|
|
13
|
+
reducer: ScenarioHostReducer;
|
|
14
|
+
activeTransportCount(): number;
|
|
15
|
+
waitForQuiescence(timeoutMs: number): Promise<void>;
|
|
16
|
+
dispose(): Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare function createInvocationBarrier(caseId: string): InvocationBarrier;
|
|
19
|
+
export declare function startScenarioHttpHost(options: {
|
|
20
|
+
artifact: ScenarioArtifact;
|
|
21
|
+
reducer: ScenarioHostReducer;
|
|
22
|
+
barrier?: InvocationBarrier;
|
|
23
|
+
secretRef?: string;
|
|
24
|
+
}): Promise<ScenarioHttpHost>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof, anti-slop/no-unknown-returns -- This module parses untrusted HTTP and JSON-RPC input at the host boundary. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
|
+
const PATHGRADE_ERROR_CODES = new Map([
|
|
9
|
+
['TURN_NOT_ACTIVE', -32010],
|
|
10
|
+
['STALE_TURN_EPOCH', -32011],
|
|
11
|
+
['SCENARIO_AMBIGUOUS', -32020],
|
|
12
|
+
['SCENARIO_CONCURRENT_EFFECT', -32021],
|
|
13
|
+
['SCENARIO_CANCELLED', -32022],
|
|
14
|
+
['SCENARIO_DELIVERY_UNKNOWN', -32023],
|
|
15
|
+
]);
|
|
16
|
+
export function createInvocationBarrier(caseId) {
|
|
17
|
+
let release;
|
|
18
|
+
let reached;
|
|
19
|
+
const released = new Promise((resolve) => { release = resolve; });
|
|
20
|
+
const observed = new Promise((resolve) => { reached = resolve; });
|
|
21
|
+
let didReach = false;
|
|
22
|
+
return {
|
|
23
|
+
caseId,
|
|
24
|
+
async wait() {
|
|
25
|
+
if (!didReach) {
|
|
26
|
+
didReach = true;
|
|
27
|
+
reached();
|
|
28
|
+
}
|
|
29
|
+
await released;
|
|
30
|
+
},
|
|
31
|
+
release,
|
|
32
|
+
reached: () => observed,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function startScenarioHttpHost(options) {
|
|
36
|
+
const bearer = Buffer.from(randomBytes(32).toString('base64url'));
|
|
37
|
+
const routes = new Map();
|
|
38
|
+
for (const serverId of Object.keys(options.artifact.canonicalIr.servers).sort()) {
|
|
39
|
+
routes.set(`/${randomBytes(18).toString('base64url')}`, serverId);
|
|
40
|
+
}
|
|
41
|
+
const activeTransports = new Set();
|
|
42
|
+
const quiescenceWaiters = new Set();
|
|
43
|
+
let closing = false;
|
|
44
|
+
const listener = createServer(async (request, response) => {
|
|
45
|
+
await handleRequest(request, response).catch((error) => {
|
|
46
|
+
if (!response.headersSent)
|
|
47
|
+
sendText(response, 500, 'scenario host failure');
|
|
48
|
+
else
|
|
49
|
+
response.end();
|
|
50
|
+
if (options.reducer.phase !== 'POISONED' && options.reducer.phase !== 'DISPOSED') {
|
|
51
|
+
try {
|
|
52
|
+
if (options.reducer.phase === 'ACTIVE')
|
|
53
|
+
options.reducer.fence();
|
|
54
|
+
if (options.reducer.phase === 'FENCING')
|
|
55
|
+
options.reducer.abortUncommitted('host-threw');
|
|
56
|
+
options.reducer.finalize('poisoned', error instanceof Error ? error.name : 'host-threw');
|
|
57
|
+
}
|
|
58
|
+
catch (lifecycleFailure) {
|
|
59
|
+
// Cleanup owns this secondary failure and reports remaining resources.
|
|
60
|
+
void lifecycleFailure;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
async function handleRequest(request, response) {
|
|
66
|
+
if (closing)
|
|
67
|
+
return sendText(response, 503, 'closing');
|
|
68
|
+
if (request.headers.host !== '127.0.0.1' && !request.headers.host?.startsWith('127.0.0.1:'))
|
|
69
|
+
return sendText(response, 400, 'invalid host');
|
|
70
|
+
const authorization = request.headers.authorization;
|
|
71
|
+
if (Array.isArray(authorization) || authorization !== `Bearer ${bearer.toString()}`)
|
|
72
|
+
return sendText(response, 401, 'unauthorized');
|
|
73
|
+
if (request.method !== 'POST')
|
|
74
|
+
return sendText(response, 405, 'method not allowed');
|
|
75
|
+
const serverId = routes.get(request.url ?? '');
|
|
76
|
+
if (!serverId)
|
|
77
|
+
return sendText(response, 404, 'not found');
|
|
78
|
+
const body = await readJsonBody(request);
|
|
79
|
+
if (body === undefined)
|
|
80
|
+
return sendText(response, 400, 'invalid JSON');
|
|
81
|
+
const declaredServer = options.artifact.canonicalIr.servers[serverId];
|
|
82
|
+
let committedInvocationId;
|
|
83
|
+
const serverOptions = { capabilities: { tools: {} } };
|
|
84
|
+
if (declaredServer.instructions)
|
|
85
|
+
serverOptions.instructions = declaredServer.instructions;
|
|
86
|
+
const server = new Server({ name: serverId, version: '2.0.0' }, serverOptions);
|
|
87
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
88
|
+
tools: Object.entries(declaredServer.tools).map(([toolId, tool]) => ({
|
|
89
|
+
name: toolId,
|
|
90
|
+
description: tool.description,
|
|
91
|
+
inputSchema: tool.inputSchema,
|
|
92
|
+
// Scenario transitions mutate only parent-owned mock state. They are
|
|
93
|
+
// not real-world side effects and must not enter provider approval.
|
|
94
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
95
|
+
})),
|
|
96
|
+
}));
|
|
97
|
+
server.setRequestHandler(CallToolRequestSchema, async (call) => {
|
|
98
|
+
const toolId = call.params.name;
|
|
99
|
+
const args = asJsonObject(call.params.arguments ?? {});
|
|
100
|
+
if (!args)
|
|
101
|
+
throw new McpError(-32602, 'Tool arguments must be a JSON object');
|
|
102
|
+
const received = options.reducer.receive(serverId, toolId, args);
|
|
103
|
+
if (!received.ok)
|
|
104
|
+
throw hostError(received.code, received.invocationId);
|
|
105
|
+
const invocationId = received.value.invocationId;
|
|
106
|
+
const matched = options.reducer.match(invocationId);
|
|
107
|
+
if (!matched.ok) {
|
|
108
|
+
if (matched.code === 'SCENARIO_INVALID_ARGUMENTS') {
|
|
109
|
+
return toolError({ kind: 'tool-error', code: matched.code, message: 'Scenario arguments are invalid', data: { issues: matched.issues ?? [] } });
|
|
110
|
+
}
|
|
111
|
+
if (matched.code === 'SCENARIO_NO_MATCH')
|
|
112
|
+
return toolError({ kind: 'tool-error', code: matched.code, message: matched.message ?? 'No scenario case matched' });
|
|
113
|
+
throw hostError(matched.code, invocationId);
|
|
114
|
+
}
|
|
115
|
+
if (matched.value.outcome.kind === 'tool-error')
|
|
116
|
+
return toolError(matched.value.outcome);
|
|
117
|
+
const prepared = options.reducer.prepare(invocationId);
|
|
118
|
+
if (!prepared.ok)
|
|
119
|
+
throw hostError(prepared.code, invocationId);
|
|
120
|
+
const barrierCase = options.artifact.canonicalIr.states[options.reducer.state]?.tools[`${serverId}/${toolId}`]?.cases.find((entry) => entry.id === options.barrier?.caseId);
|
|
121
|
+
if (barrierCase?.outcome === matched.value.outcome)
|
|
122
|
+
await options.barrier?.wait();
|
|
123
|
+
const committed = options.reducer.commit(invocationId);
|
|
124
|
+
if (!committed.ok)
|
|
125
|
+
throw hostError(committed.code, invocationId);
|
|
126
|
+
committedInvocationId = invocationId;
|
|
127
|
+
const outcome = committed.value.outcome;
|
|
128
|
+
if (outcome.kind !== 'success')
|
|
129
|
+
throw new McpError(-32603, 'Committed outcome was not successful');
|
|
130
|
+
if (outcome.structuredContent)
|
|
131
|
+
return { content: outcome.content, structuredContent: outcome.structuredContent };
|
|
132
|
+
return { content: outcome.content };
|
|
133
|
+
});
|
|
134
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
135
|
+
activeTransports.add(transport);
|
|
136
|
+
const releaseTransport = () => {
|
|
137
|
+
activeTransports.delete(transport);
|
|
138
|
+
if (activeTransports.size === 0) {
|
|
139
|
+
for (const resolve of quiescenceWaiters)
|
|
140
|
+
resolve();
|
|
141
|
+
quiescenceWaiters.clear();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
transport.onclose = releaseTransport;
|
|
145
|
+
try {
|
|
146
|
+
await server.connect(transport);
|
|
147
|
+
await transport.handleRequest(request, response, body);
|
|
148
|
+
if (committedInvocationId)
|
|
149
|
+
options.reducer.delivery(committedInvocationId, 'response-written');
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (committedInvocationId && options.reducer.phase !== 'POISONED')
|
|
153
|
+
options.reducer.delivery(committedInvocationId, 'unknown');
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await server.close().catch(() => transport.close().catch(() => undefined));
|
|
158
|
+
releaseTransport();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
listener.once('error', reject);
|
|
163
|
+
listener.listen(0, '127.0.0.1', resolve);
|
|
164
|
+
});
|
|
165
|
+
const address = listener.address();
|
|
166
|
+
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
|
|
167
|
+
await closeListener(listener);
|
|
168
|
+
throw new Error('scenario host did not bind IPv4 loopback');
|
|
169
|
+
}
|
|
170
|
+
const secretRef = options.secretRef ?? 'codex-direct-mcp-trial-bearer';
|
|
171
|
+
const endpoints = [...routes].map(([route, serverId]) => ({
|
|
172
|
+
serverId,
|
|
173
|
+
transport: 'streamable-http',
|
|
174
|
+
url: `http://127.0.0.1:${address.port}${route}`,
|
|
175
|
+
headers: [{ name: 'Authorization', valueRef: secretRef, exposure: 'control-plane-only' }],
|
|
176
|
+
lifetime: 'trial',
|
|
177
|
+
authority: 'scenario-host',
|
|
178
|
+
}));
|
|
179
|
+
return {
|
|
180
|
+
endpoints,
|
|
181
|
+
secretRef,
|
|
182
|
+
bearer,
|
|
183
|
+
reducer: options.reducer,
|
|
184
|
+
activeTransportCount: () => activeTransports.size,
|
|
185
|
+
waitForQuiescence(timeoutMs) {
|
|
186
|
+
if (activeTransports.size === 0)
|
|
187
|
+
return Promise.resolve();
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const onQuiescent = () => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
resolve();
|
|
192
|
+
};
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
quiescenceWaiters.delete(onQuiescent);
|
|
195
|
+
reject(new Error('Timed out waiting for scenario host quiescence'));
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
quiescenceWaiters.add(onQuiescent);
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
async dispose() {
|
|
201
|
+
if (closing)
|
|
202
|
+
return;
|
|
203
|
+
closing = true;
|
|
204
|
+
await Promise.all([...activeTransports].map((transport) => transport.close().catch(() => undefined)));
|
|
205
|
+
await closeListener(listener);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function toolError(error) {
|
|
210
|
+
const pathgrade = { version: 1, code: error.code, message: error.message };
|
|
211
|
+
if (error.data)
|
|
212
|
+
pathgrade.data = error.data;
|
|
213
|
+
return {
|
|
214
|
+
isError: true,
|
|
215
|
+
content: [{ type: 'text', text: error.message }],
|
|
216
|
+
structuredContent: { pathgrade },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function hostError(code, invocationId) {
|
|
220
|
+
const data = { pathgradeCode: code };
|
|
221
|
+
if (invocationId)
|
|
222
|
+
data.invocationId = invocationId;
|
|
223
|
+
return new McpError(PATHGRADE_ERROR_CODES.get(code) ?? -32603, code, data);
|
|
224
|
+
}
|
|
225
|
+
function asJsonObject(value) {
|
|
226
|
+
// SAFETY: the JSON parser guarantees recursive JSON values; this check narrows the root to a non-array object.
|
|
227
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
228
|
+
}
|
|
229
|
+
async function readJsonBody(request) {
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let size = 0;
|
|
232
|
+
for await (const chunk of request) {
|
|
233
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
234
|
+
size += bytes.length;
|
|
235
|
+
if (size > MAX_BODY_BYTES)
|
|
236
|
+
return undefined;
|
|
237
|
+
chunks.push(bytes);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function sendText(response, status, text) {
|
|
247
|
+
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
248
|
+
response.end(text);
|
|
249
|
+
}
|
|
250
|
+
async function closeListener(listener) {
|
|
251
|
+
if (!listener.listening)
|
|
252
|
+
return;
|
|
253
|
+
await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()));
|
|
254
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
export type JsonPrimitive = null | boolean | number | string;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
|
|
3
|
+
export type JsonObject = {
|
|
4
|
+
[key: string]: JsonValue;
|
|
5
|
+
};
|
|
6
|
+
export type Sha256 = string;
|
|
7
|
+
export type HmacSha256 = string;
|
|
8
|
+
export type ProviderId = 'claude' | 'codex' | 'cursor' | 'opencode';
|
|
9
|
+
export type MatchPredicate = {
|
|
10
|
+
pointer: string;
|
|
11
|
+
equals: JsonValue;
|
|
12
|
+
} | {
|
|
13
|
+
pointer: string;
|
|
14
|
+
present: true;
|
|
15
|
+
} | {
|
|
16
|
+
pointer: string;
|
|
17
|
+
absent: true;
|
|
18
|
+
};
|
|
19
|
+
export type MatchExpr = {
|
|
20
|
+
exactArgs: JsonObject;
|
|
21
|
+
} | {
|
|
22
|
+
all: [MatchPredicate, ...MatchPredicate[]];
|
|
23
|
+
};
|
|
24
|
+
export type McpContent = JsonObject & {
|
|
25
|
+
type: string;
|
|
26
|
+
};
|
|
27
|
+
export type ScenarioSuccess = {
|
|
28
|
+
kind: 'success';
|
|
29
|
+
content: McpContent[];
|
|
30
|
+
structuredContent?: JsonObject;
|
|
31
|
+
};
|
|
32
|
+
export type ScenarioToolError = {
|
|
33
|
+
kind: 'tool-error';
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
data?: JsonObject;
|
|
37
|
+
};
|
|
38
|
+
export type ScenarioRejection = {
|
|
39
|
+
id: string;
|
|
40
|
+
code: 'SCENARIO_NO_MATCH';
|
|
41
|
+
message: string;
|
|
42
|
+
};
|
|
43
|
+
export type ScenarioCase = {
|
|
44
|
+
id: string;
|
|
45
|
+
when: MatchExpr;
|
|
46
|
+
outcome: ScenarioSuccess | ScenarioToolError;
|
|
47
|
+
transition?: {
|
|
48
|
+
to: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type ScenarioMachineV2 = {
|
|
52
|
+
version: 2;
|
|
53
|
+
scenarioId: string;
|
|
54
|
+
initialState: string;
|
|
55
|
+
servers: Record<string, {
|
|
56
|
+
instructions?: string;
|
|
57
|
+
tools: Record<string, {
|
|
58
|
+
description: string;
|
|
59
|
+
inputSchema: JsonObject;
|
|
60
|
+
}>;
|
|
61
|
+
}>;
|
|
62
|
+
states: Record<string, {
|
|
63
|
+
tools: Record<string, {
|
|
64
|
+
cases: ScenarioCase[];
|
|
65
|
+
otherwise?: ScenarioRejection;
|
|
66
|
+
}>;
|
|
67
|
+
}>;
|
|
68
|
+
concurrency: 'poison-effectful-overlap';
|
|
69
|
+
};
|
|
70
|
+
export type ScenarioDiagnostic = {
|
|
71
|
+
code: string;
|
|
72
|
+
path: string;
|
|
73
|
+
message: string;
|
|
74
|
+
};
|
|
75
|
+
export type CompiledTool = {
|
|
76
|
+
qualifiedToolId: string;
|
|
77
|
+
serverId: string;
|
|
78
|
+
toolId: string;
|
|
79
|
+
cases: ScenarioCase[];
|
|
80
|
+
otherwise: ScenarioRejection;
|
|
81
|
+
effectful: boolean;
|
|
82
|
+
};
|
|
83
|
+
export type ScenarioIR = {
|
|
84
|
+
version: 2;
|
|
85
|
+
scenarioId: string;
|
|
86
|
+
initialState: string;
|
|
87
|
+
servers: ScenarioMachineV2['servers'];
|
|
88
|
+
states: Record<string, {
|
|
89
|
+
tools: Record<string, CompiledTool>;
|
|
90
|
+
}>;
|
|
91
|
+
reachableStates: string[];
|
|
92
|
+
concurrency: 'poison-effectful-overlap';
|
|
93
|
+
};
|
|
94
|
+
export type ScenarioArtifact = {
|
|
95
|
+
format: 'pathgrade-scenario-machine-v2';
|
|
96
|
+
canonicalIr: ScenarioIR;
|
|
97
|
+
artifactDigest: Sha256;
|
|
98
|
+
};
|
|
99
|
+
export type HeaderRef = {
|
|
100
|
+
name: 'Authorization';
|
|
101
|
+
valueRef: string;
|
|
102
|
+
exposure: 'control-plane-only';
|
|
103
|
+
};
|
|
104
|
+
export type EndpointSpec = {
|
|
105
|
+
serverId: string;
|
|
106
|
+
transport: 'streamable-http';
|
|
107
|
+
url: string;
|
|
108
|
+
headers: readonly HeaderRef[];
|
|
109
|
+
lifetime: 'trial';
|
|
110
|
+
authority: 'scenario-host';
|
|
111
|
+
};
|
|
112
|
+
export type ScenarioProviderTurn = {
|
|
113
|
+
turnId: string;
|
|
114
|
+
provider: ProviderId;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Trusted control-plane handle owned by a provider adapter. The coordinator
|
|
118
|
+
* deliberately treats `value` as opaque and owns only its lifecycle.
|
|
119
|
+
*/
|
|
120
|
+
export type ScenarioProviderAttachment<T> = {
|
|
121
|
+
value: T;
|
|
122
|
+
interrupt(turn: ScenarioProviderTurn, reason: string): Promise<void>;
|
|
123
|
+
waitForTerminal(turn: ScenarioProviderTurn): Promise<void>;
|
|
124
|
+
close(): Promise<void>;
|
|
125
|
+
};
|
|
126
|
+
export type ScenarioProviderAttachContext = {
|
|
127
|
+
artifact: ScenarioArtifact;
|
|
128
|
+
endpoints: readonly EndpointSpec[];
|
|
129
|
+
resolveSecret(secretRef: string): string;
|
|
130
|
+
};
|
|
131
|
+
export type RuntimeCapabilityProfile = {
|
|
132
|
+
format: 'pathgrade-direct-mcp-runtime-profile-v1';
|
|
133
|
+
provider: ProviderId;
|
|
134
|
+
platform: string;
|
|
135
|
+
architecture: string;
|
|
136
|
+
controlPlane: 'native-sdk-parent-bridge' | 'app-server' | 'acp-v1';
|
|
137
|
+
command: {
|
|
138
|
+
absolutePath: string;
|
|
139
|
+
args: readonly string[];
|
|
140
|
+
versionArgs: readonly string[];
|
|
141
|
+
};
|
|
142
|
+
expectedVersion: string;
|
|
143
|
+
artifacts: readonly {
|
|
144
|
+
label: string;
|
|
145
|
+
absolutePath: string;
|
|
146
|
+
sha256: Sha256;
|
|
147
|
+
}[];
|
|
148
|
+
requiredCapabilities: {
|
|
149
|
+
streamableHttp: true;
|
|
150
|
+
controlPlaneHeaders: true;
|
|
151
|
+
parentOwnedSdkMcp?: true;
|
|
152
|
+
cancellation: 'abort-controller' | 'turn-interrupt' | 'session-cancel';
|
|
153
|
+
};
|
|
154
|
+
profileFingerprint: Sha256;
|
|
155
|
+
runtimeCapabilityFingerprint: Sha256;
|
|
156
|
+
};
|
|
157
|
+
export type AdmittedRuntimeProfile = RuntimeCapabilityProfile & {
|
|
158
|
+
admitted: true;
|
|
159
|
+
};
|
|
160
|
+
export type ScenarioEvidence = {
|
|
161
|
+
type: 'turn.begun';
|
|
162
|
+
epoch: number;
|
|
163
|
+
provider: ProviderId;
|
|
164
|
+
} | {
|
|
165
|
+
type: 'mcp.invocation';
|
|
166
|
+
invocationId: string;
|
|
167
|
+
receivedSeq: number;
|
|
168
|
+
turnEpoch: number;
|
|
169
|
+
serverId: string;
|
|
170
|
+
toolId: string;
|
|
171
|
+
argsMac: HmacSha256;
|
|
172
|
+
replayRequestRef: string;
|
|
173
|
+
snapshotSeq: number;
|
|
174
|
+
stateBefore: string;
|
|
175
|
+
} | {
|
|
176
|
+
type: 'mcp.decision';
|
|
177
|
+
invocationId: string;
|
|
178
|
+
authority: 'scenario-host';
|
|
179
|
+
outcome: 'matched' | 'no-match' | 'ambiguous';
|
|
180
|
+
caseId?: string;
|
|
181
|
+
} | {
|
|
182
|
+
type: 'mcp.prepared';
|
|
183
|
+
invocationId: string;
|
|
184
|
+
turnEpoch: number;
|
|
185
|
+
} | {
|
|
186
|
+
type: 'mcp.commit';
|
|
187
|
+
invocationId: string;
|
|
188
|
+
committedEpoch: number;
|
|
189
|
+
stateBefore: string;
|
|
190
|
+
stateAfter: string;
|
|
191
|
+
outcomeMac: HmacSha256;
|
|
192
|
+
} | {
|
|
193
|
+
type: 'mcp.abort';
|
|
194
|
+
invocationId: string;
|
|
195
|
+
phase: 'received' | 'matched' | 'prepared';
|
|
196
|
+
reason: string;
|
|
197
|
+
} | {
|
|
198
|
+
type: 'mcp.delivery';
|
|
199
|
+
invocationId: string;
|
|
200
|
+
status: 'response-written' | 'cancelled' | 'unknown';
|
|
201
|
+
} | {
|
|
202
|
+
type: 'turn.fenced';
|
|
203
|
+
previousEpoch: number;
|
|
204
|
+
nextEpoch: number;
|
|
205
|
+
} | {
|
|
206
|
+
type: 'domain.poisoned';
|
|
207
|
+
reason: string;
|
|
208
|
+
invocationIds: string[];
|
|
209
|
+
} | {
|
|
210
|
+
type: 'turn.finalized';
|
|
211
|
+
status: 'settled' | 'cancelled-clean' | 'poisoned';
|
|
212
|
+
reason?: string;
|
|
213
|
+
state?: string;
|
|
214
|
+
};
|
|
215
|
+
export type EvidenceEnvelope = {
|
|
216
|
+
trialId: string;
|
|
217
|
+
turnId: string;
|
|
218
|
+
seq: number;
|
|
219
|
+
previousEventHash: Sha256 | null;
|
|
220
|
+
eventHash: Sha256;
|
|
221
|
+
artifactDigest: Sha256;
|
|
222
|
+
payload: ScenarioEvidence;
|
|
223
|
+
};
|
|
224
|
+
export type ProviderCorroboration = {
|
|
225
|
+
provider: ProviderId;
|
|
226
|
+
providerCallId?: string;
|
|
227
|
+
serverId?: string;
|
|
228
|
+
toolId?: string;
|
|
229
|
+
inputDigest?: string;
|
|
230
|
+
outputDigest?: string;
|
|
231
|
+
phase: string;
|
|
232
|
+
order: number;
|
|
233
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|