@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,79 @@
|
|
|
1
|
+
import { access, realpath } from 'node:fs/promises';
|
|
2
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
export const CURSOR_DIRECT_MCP_NORMALIZED_HANDSHAKE = {
|
|
5
|
+
normalizerVersion: 'pathgrade-acp-v1-capabilities-v1',
|
|
6
|
+
provider: 'cursor',
|
|
7
|
+
protocolVersion: 1,
|
|
8
|
+
httpMcp: true,
|
|
9
|
+
sessionClose: false,
|
|
10
|
+
};
|
|
11
|
+
/** Reviewed macOS arm64 pin captured by RFC 0006. Other platforms fail closed. */
|
|
12
|
+
export const CURSOR_DIRECT_MCP_DARWIN_ARM64_PROFILE = {
|
|
13
|
+
format: 'pathgrade-direct-mcp-runtime-profile-v1',
|
|
14
|
+
provider: 'cursor',
|
|
15
|
+
platform: 'darwin',
|
|
16
|
+
architecture: 'arm64',
|
|
17
|
+
controlPlane: 'acp-v1',
|
|
18
|
+
command: {
|
|
19
|
+
absolutePath: '/Users/nadavlac/.local/share/cursor-agent/versions/2026.06.26-7079533/cursor-agent',
|
|
20
|
+
args: ['acp'],
|
|
21
|
+
versionArgs: ['--version'],
|
|
22
|
+
},
|
|
23
|
+
expectedVersion: '2026.06.26-7079533',
|
|
24
|
+
artifacts: [
|
|
25
|
+
{
|
|
26
|
+
label: 'cursor-wrapper',
|
|
27
|
+
absolutePath: '/Users/nadavlac/.local/share/cursor-agent/versions/2026.06.26-7079533/cursor-agent',
|
|
28
|
+
sha256: 'b7babf47d8b1eee28ac27a74affa02a559bb38103a6e71fbb1f120805d51fedf',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'cursor-index',
|
|
32
|
+
absolutePath: '/Users/nadavlac/.local/share/cursor-agent/versions/2026.06.26-7079533/index.js',
|
|
33
|
+
sha256: '80eca3cae34cd9af1ee63cdf962eaad42d2a720a2268866e6467aca1b5ab5de0',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: 'cursor-node',
|
|
37
|
+
absolutePath: '/Users/nadavlac/.local/share/cursor-agent/versions/2026.06.26-7079533/node',
|
|
38
|
+
sha256: '336b5b3ebc5deb86df842102b20b6e4761605b7a667823e68dda7761b91a161b',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
requiredCapabilities: { streamableHttp: true, controlPlaneHeaders: true, cancellation: 'session-cancel' },
|
|
42
|
+
profileFingerprint: 'e75d2763766bf6b2ab9005bd1ebaf252a207c8c3a63481484b007d81edebb818',
|
|
43
|
+
runtimeCapabilityFingerprint: '75d455ba86260766996f533485c54657764095af306e72a4f4616b759eedf59b',
|
|
44
|
+
};
|
|
45
|
+
/** Code-owned portable identity for the Cursor ACP author-session slice. */
|
|
46
|
+
export const CURSOR_ACP_AUTHOR_DARWIN_ARM64_LOCK = {
|
|
47
|
+
format: 'pathgrade-direct-mcp-runtime-lock-v1',
|
|
48
|
+
provider: 'cursor',
|
|
49
|
+
platform: 'darwin',
|
|
50
|
+
architecture: 'arm64',
|
|
51
|
+
controlPlane: 'acp-v1',
|
|
52
|
+
version: '2026.06.26-7079533',
|
|
53
|
+
commandArtifact: 'cursor-wrapper',
|
|
54
|
+
args: ['acp'],
|
|
55
|
+
versionArgs: ['--version'],
|
|
56
|
+
artifacts: [
|
|
57
|
+
{ id: 'cursor-wrapper', relativePath: 'cursor-agent', sha256: 'b7babf47d8b1eee28ac27a74affa02a559bb38103a6e71fbb1f120805d51fedf' },
|
|
58
|
+
{ id: 'cursor-index', relativePath: 'index.js', sha256: '80eca3cae34cd9af1ee63cdf962eaad42d2a720a2268866e6467aca1b5ab5de0' },
|
|
59
|
+
{ id: 'cursor-node', relativePath: 'node', sha256: '336b5b3ebc5deb86df842102b20b6e4761605b7a667823e68dda7761b91a161b' },
|
|
60
|
+
],
|
|
61
|
+
capabilities: { streamableHttp: true, controlPlaneHeaders: true, cancellation: 'session-cancel' },
|
|
62
|
+
normalizedHandshakeSha256: '75d455ba86260766996f533485c54657764095af306e72a4f4616b759eedf59b',
|
|
63
|
+
};
|
|
64
|
+
/** Resolve ordinary Cursor command discovery before exact lock admission. */
|
|
65
|
+
export async function resolveCursorExecutable(environment, command = 'cursor-agent') {
|
|
66
|
+
const candidates = isAbsolute(command)
|
|
67
|
+
? [command]
|
|
68
|
+
: (environment.PATH ?? '').split(delimiter).filter(Boolean).map((directory) => join(directory, command));
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
await access(candidate, constants.X_OK);
|
|
72
|
+
return await realpath(candidate);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Continue through the same PATH order used by the ordinary Cursor command.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: Cursor executable ${command} was not found on PATH`);
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { EvidenceEnvelope, JsonValue, ProviderCorroboration, ScenarioArtifact, ScenarioEvidence } from './types.js';
|
|
2
|
+
export declare function valueMac(key: Uint8Array, value: unknown): string;
|
|
3
|
+
export declare class PrivateReplayVault {
|
|
4
|
+
private readonly encryptionKey;
|
|
5
|
+
private readonly entries;
|
|
6
|
+
private nextReference;
|
|
7
|
+
private disposed;
|
|
8
|
+
constructor(encryptionKey?: Buffer);
|
|
9
|
+
store(value: JsonValue): string;
|
|
10
|
+
resolve(reference: string): JsonValue;
|
|
11
|
+
exportEncrypted(): Readonly<Record<string, {
|
|
12
|
+
iv: string;
|
|
13
|
+
authTag: string;
|
|
14
|
+
ciphertext: string;
|
|
15
|
+
}>>;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
private assertOpen;
|
|
18
|
+
}
|
|
19
|
+
export declare class EvidenceLedger {
|
|
20
|
+
private readonly trialId;
|
|
21
|
+
private readonly artifactDigest;
|
|
22
|
+
private readonly envelopes;
|
|
23
|
+
constructor(trialId: string, artifactDigest: string);
|
|
24
|
+
append(turnId: string, payload: ScenarioEvidence): EvidenceEnvelope;
|
|
25
|
+
events(): readonly EvidenceEnvelope[];
|
|
26
|
+
}
|
|
27
|
+
export declare function verifyEvidenceChain(envelopes: readonly EvidenceEnvelope[], artifactDigest?: string): {
|
|
28
|
+
ok: true;
|
|
29
|
+
finalEventHash: string | null;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
index: number;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
export type ReplayResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
finalState: string;
|
|
38
|
+
poisoned: boolean;
|
|
39
|
+
finalEventHash: string | null;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
reason: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function replayPrivateEvidence(options: {
|
|
45
|
+
artifact: ScenarioArtifact;
|
|
46
|
+
evidence: readonly EvidenceEnvelope[];
|
|
47
|
+
vault: PrivateReplayVault;
|
|
48
|
+
macKey: Uint8Array;
|
|
49
|
+
providerCorroboration?: readonly ProviderCorroboration[];
|
|
50
|
+
}): ReplayResult;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- Canonical MAC input and decrypted JSON are validated at this cryptographic replay boundary. */
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto';
|
|
3
|
+
import { canonicalJson, parseStrictJson, sha256Canonical } from './json.js';
|
|
4
|
+
import { matchExpression } from './compiler.js';
|
|
5
|
+
export function valueMac(key, value) {
|
|
6
|
+
return createHmac('sha256', key).update(canonicalJson(value)).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
export class PrivateReplayVault {
|
|
9
|
+
encryptionKey;
|
|
10
|
+
entries = new Map();
|
|
11
|
+
nextReference = 1;
|
|
12
|
+
disposed = false;
|
|
13
|
+
constructor(encryptionKey = randomBytes(32)) {
|
|
14
|
+
this.encryptionKey = encryptionKey;
|
|
15
|
+
if (encryptionKey.byteLength !== 32)
|
|
16
|
+
throw new TypeError('replay encryption key must be 32 bytes');
|
|
17
|
+
}
|
|
18
|
+
store(value) {
|
|
19
|
+
this.assertOpen();
|
|
20
|
+
const reference = `request-${this.nextReference++}`;
|
|
21
|
+
const iv = randomBytes(12);
|
|
22
|
+
const cipher = createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
|
23
|
+
const ciphertext = Buffer.concat([cipher.update(canonicalJson(value), 'utf8'), cipher.final()]);
|
|
24
|
+
this.entries.set(reference, { iv: iv.toString('base64url'), authTag: cipher.getAuthTag().toString('base64url'), ciphertext: ciphertext.toString('base64url') });
|
|
25
|
+
return reference;
|
|
26
|
+
}
|
|
27
|
+
resolve(reference) {
|
|
28
|
+
this.assertOpen();
|
|
29
|
+
const entry = this.entries.get(reference);
|
|
30
|
+
if (!entry)
|
|
31
|
+
throw new Error(`unknown replay reference: ${reference}`);
|
|
32
|
+
const decipher = createDecipheriv('aes-256-gcm', this.encryptionKey, Buffer.from(entry.iv, 'base64url'));
|
|
33
|
+
decipher.setAuthTag(Buffer.from(entry.authTag, 'base64url'));
|
|
34
|
+
return parseStrictJson(Buffer.concat([decipher.update(Buffer.from(entry.ciphertext, 'base64url')), decipher.final()]).toString('utf8'));
|
|
35
|
+
}
|
|
36
|
+
exportEncrypted() {
|
|
37
|
+
this.assertOpen();
|
|
38
|
+
return Object.fromEntries(this.entries);
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
if (this.disposed)
|
|
42
|
+
return;
|
|
43
|
+
this.encryptionKey.fill(0);
|
|
44
|
+
this.entries.clear();
|
|
45
|
+
this.disposed = true;
|
|
46
|
+
}
|
|
47
|
+
assertOpen() {
|
|
48
|
+
if (this.disposed)
|
|
49
|
+
throw new Error('replay vault is disposed');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class EvidenceLedger {
|
|
53
|
+
trialId;
|
|
54
|
+
artifactDigest;
|
|
55
|
+
envelopes = [];
|
|
56
|
+
constructor(trialId, artifactDigest) {
|
|
57
|
+
this.trialId = trialId;
|
|
58
|
+
this.artifactDigest = artifactDigest;
|
|
59
|
+
}
|
|
60
|
+
append(turnId, payload) {
|
|
61
|
+
const seq = this.envelopes.length + 1;
|
|
62
|
+
const previousEventHash = this.envelopes.at(-1)?.eventHash ?? null;
|
|
63
|
+
const withoutHash = { trialId: this.trialId, turnId, seq, previousEventHash, artifactDigest: this.artifactDigest, payload };
|
|
64
|
+
const envelope = { ...withoutHash, eventHash: sha256Canonical(withoutHash) };
|
|
65
|
+
this.envelopes.push(envelope);
|
|
66
|
+
return envelope;
|
|
67
|
+
}
|
|
68
|
+
events() { return this.envelopes; }
|
|
69
|
+
}
|
|
70
|
+
export function verifyEvidenceChain(envelopes, artifactDigest) {
|
|
71
|
+
let previousEventHash = null;
|
|
72
|
+
for (const [index, envelope] of envelopes.entries()) {
|
|
73
|
+
if (envelope.seq !== index + 1)
|
|
74
|
+
return { ok: false, index, reason: 'non-contiguous sequence' };
|
|
75
|
+
if (envelope.previousEventHash !== previousEventHash)
|
|
76
|
+
return { ok: false, index, reason: 'previousEventHash mismatch' };
|
|
77
|
+
if (artifactDigest !== undefined && envelope.artifactDigest !== artifactDigest)
|
|
78
|
+
return { ok: false, index, reason: 'artifactDigest mismatch' };
|
|
79
|
+
const { eventHash, ...withoutHash } = envelope;
|
|
80
|
+
if (sha256Canonical(withoutHash) !== eventHash)
|
|
81
|
+
return { ok: false, index, reason: 'eventHash mismatch' };
|
|
82
|
+
previousEventHash = eventHash;
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, finalEventHash: previousEventHash };
|
|
85
|
+
}
|
|
86
|
+
function selectCase(artifact, state, serverId, toolId, args) {
|
|
87
|
+
const tool = artifact.canonicalIr.states[state]?.tools[`${serverId}/${toolId}`];
|
|
88
|
+
if (!tool)
|
|
89
|
+
return { outcome: 'no-match' };
|
|
90
|
+
const matches = tool.cases.filter((scenarioCase) => matchExpression(args, scenarioCase.when));
|
|
91
|
+
if (matches.length > 1)
|
|
92
|
+
return { outcome: 'ambiguous' };
|
|
93
|
+
if (matches.length === 0)
|
|
94
|
+
return { outcome: 'no-match', rejection: tool.otherwise };
|
|
95
|
+
return { outcome: 'matched', scenarioCase: matches[0] };
|
|
96
|
+
}
|
|
97
|
+
export function replayPrivateEvidence(options) {
|
|
98
|
+
const chain = verifyEvidenceChain(options.evidence, options.artifact.artifactDigest);
|
|
99
|
+
if (!chain.ok)
|
|
100
|
+
return { ok: false, reason: `evidence chain: ${chain.reason}` };
|
|
101
|
+
let state = options.artifact.canonicalIr.initialState;
|
|
102
|
+
let poisoned = false;
|
|
103
|
+
const invocations = new Map();
|
|
104
|
+
for (const envelope of options.evidence) {
|
|
105
|
+
const payload = envelope.payload;
|
|
106
|
+
if (payload.type === 'mcp.invocation') {
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = options.vault.resolve(payload.replayRequestRef);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { ok: false, reason: `replay vault: ${error instanceof Error ? error.message : String(error)}` };
|
|
113
|
+
}
|
|
114
|
+
if (raw === null || Array.isArray(raw) || typeof raw !== 'object')
|
|
115
|
+
return { ok: false, reason: `replay request ${payload.replayRequestRef} is not an object` };
|
|
116
|
+
if (valueMac(options.macKey, raw) !== payload.argsMac)
|
|
117
|
+
return { ok: false, reason: `argument MAC mismatch for ${payload.invocationId}` };
|
|
118
|
+
invocations.set(payload.invocationId, { args: raw, selected: selectCase(options.artifact, state, payload.serverId, payload.toolId, raw) });
|
|
119
|
+
}
|
|
120
|
+
else if (payload.type === 'mcp.decision') {
|
|
121
|
+
const invocation = invocations.get(payload.invocationId);
|
|
122
|
+
if (!invocation || invocation.selected.outcome !== payload.outcome)
|
|
123
|
+
return { ok: false, reason: `decision mismatch for ${payload.invocationId}` };
|
|
124
|
+
if (payload.outcome === 'matched' && invocation.selected.scenarioCase?.id !== payload.caseId)
|
|
125
|
+
return { ok: false, reason: `case mismatch for ${payload.invocationId}` };
|
|
126
|
+
}
|
|
127
|
+
else if (payload.type === 'mcp.commit') {
|
|
128
|
+
const invocation = invocations.get(payload.invocationId);
|
|
129
|
+
if (!invocation || invocation.selected.outcome !== 'matched')
|
|
130
|
+
return { ok: false, reason: `commit without matched invocation ${payload.invocationId}` };
|
|
131
|
+
const scenarioCase = invocation.selected.scenarioCase;
|
|
132
|
+
if (payload.stateBefore !== state)
|
|
133
|
+
return { ok: false, reason: `stateBefore mismatch for ${payload.invocationId}` };
|
|
134
|
+
const expectedAfter = scenarioCase.transition?.to ?? state;
|
|
135
|
+
if (payload.stateAfter !== expectedAfter || valueMac(options.macKey, scenarioCase.outcome) !== payload.outcomeMac)
|
|
136
|
+
return { ok: false, reason: `commit outcome mismatch for ${payload.invocationId}` };
|
|
137
|
+
state = expectedAfter;
|
|
138
|
+
}
|
|
139
|
+
else if (payload.type === 'domain.poisoned') {
|
|
140
|
+
poisoned = true;
|
|
141
|
+
}
|
|
142
|
+
else if (payload.type === 'turn.finalized' && payload.state !== undefined && payload.state !== state) {
|
|
143
|
+
return { ok: false, reason: 'finalized state mismatch' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, finalState: state, poisoned, finalEventHash: chain.finalEventHash };
|
|
147
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { EvidenceLedger, PrivateReplayVault } from './evidence.js';
|
|
2
|
+
import type { JsonObject, ProviderId, ScenarioArtifact, ScenarioCase } from './types.js';
|
|
3
|
+
export type TrialPhase = 'NEW' | 'COMPILED' | 'ADMITTED' | 'READY' | 'ACTIVE' | 'FENCING' | 'QUIESCING' | 'POISONED' | 'DISPOSING' | 'DISPOSED';
|
|
4
|
+
export type InvocationPhase = 'RECEIVED' | 'MATCHED' | 'PREPARED' | 'COMMITTED' | 'DELIVERED' | 'REJECTED' | 'ABORTED' | 'DELIVERY_UNKNOWN';
|
|
5
|
+
export type HostResult<T = undefined> = {
|
|
6
|
+
ok: true;
|
|
7
|
+
value: T;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
code: string;
|
|
11
|
+
message?: string;
|
|
12
|
+
invocationId?: string;
|
|
13
|
+
issues?: {
|
|
14
|
+
instancePointer: string;
|
|
15
|
+
schemaPointer: string;
|
|
16
|
+
keyword: string;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
export declare class ScenarioHostReducer {
|
|
20
|
+
readonly trialId: string;
|
|
21
|
+
readonly artifact: ScenarioArtifact;
|
|
22
|
+
private readonly macKey;
|
|
23
|
+
phase: TrialPhase;
|
|
24
|
+
state: string;
|
|
25
|
+
epoch: number;
|
|
26
|
+
turnId?: string;
|
|
27
|
+
readonly ledger: EvidenceLedger;
|
|
28
|
+
readonly replayVault: PrivateReplayVault;
|
|
29
|
+
private readonly validators;
|
|
30
|
+
private readonly invocations;
|
|
31
|
+
private receivedSeq;
|
|
32
|
+
private committedSeq;
|
|
33
|
+
private providerTerminal;
|
|
34
|
+
private finalizedTurnIds;
|
|
35
|
+
constructor(trialId: string, artifact: ScenarioArtifact, macKey: Uint8Array, replayVault?: PrivateReplayVault);
|
|
36
|
+
markCompiled(): void;
|
|
37
|
+
markAdmitted(): void;
|
|
38
|
+
markReady(): void;
|
|
39
|
+
beginTurn(turnId: string, provider: ProviderId): void;
|
|
40
|
+
receive(serverId: string, toolId: string, args: JsonObject): HostResult<{
|
|
41
|
+
invocationId: string;
|
|
42
|
+
}>;
|
|
43
|
+
match(invocationId: string): HostResult<{
|
|
44
|
+
outcome: ScenarioCase['outcome'];
|
|
45
|
+
}>;
|
|
46
|
+
prepare(invocationId: string): HostResult;
|
|
47
|
+
commit(invocationId: string): HostResult<{
|
|
48
|
+
committedSeq: number;
|
|
49
|
+
outcome: ScenarioCase['outcome'];
|
|
50
|
+
}>;
|
|
51
|
+
delivery(invocationId: string, status: 'response-written' | 'cancelled' | 'unknown'): HostResult;
|
|
52
|
+
fence(): void;
|
|
53
|
+
abortUncommitted(reason?: string): void;
|
|
54
|
+
observeProviderTerminal(): void;
|
|
55
|
+
finalize(status: 'settled' | 'cancelled-clean' | 'poisoned', reason?: string): boolean;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
activeInvocationCount(): number;
|
|
58
|
+
private transition;
|
|
59
|
+
private requireInvocation;
|
|
60
|
+
private poison;
|
|
61
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
2
|
+
import { matchExpression } from './compiler.js';
|
|
3
|
+
import { EvidenceLedger, PrivateReplayVault, valueMac } from './evidence.js';
|
|
4
|
+
export class ScenarioHostReducer {
|
|
5
|
+
trialId;
|
|
6
|
+
artifact;
|
|
7
|
+
macKey;
|
|
8
|
+
phase = 'NEW';
|
|
9
|
+
state;
|
|
10
|
+
epoch = 0;
|
|
11
|
+
turnId;
|
|
12
|
+
ledger;
|
|
13
|
+
replayVault;
|
|
14
|
+
validators = new Map();
|
|
15
|
+
invocations = new Map();
|
|
16
|
+
receivedSeq = 0;
|
|
17
|
+
committedSeq = 0;
|
|
18
|
+
providerTerminal = false;
|
|
19
|
+
finalizedTurnIds = new Set();
|
|
20
|
+
constructor(trialId, artifact, macKey, replayVault = new PrivateReplayVault()) {
|
|
21
|
+
this.trialId = trialId;
|
|
22
|
+
this.artifact = artifact;
|
|
23
|
+
this.macKey = macKey;
|
|
24
|
+
this.state = artifact.canonicalIr.initialState;
|
|
25
|
+
this.ledger = new EvidenceLedger(trialId, artifact.artifactDigest);
|
|
26
|
+
this.replayVault = replayVault;
|
|
27
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false });
|
|
28
|
+
for (const [serverId, server] of Object.entries(artifact.canonicalIr.servers)) {
|
|
29
|
+
for (const [toolId, tool] of Object.entries(server.tools))
|
|
30
|
+
this.validators.set(`${serverId}/${toolId}`, ajv.compile(tool.inputSchema));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
markCompiled() { this.transition('NEW', 'COMPILED'); }
|
|
34
|
+
markAdmitted() { this.transition('COMPILED', 'ADMITTED'); }
|
|
35
|
+
markReady() { this.transition('ADMITTED', 'READY'); }
|
|
36
|
+
beginTurn(turnId, provider) {
|
|
37
|
+
if (this.phase !== 'READY')
|
|
38
|
+
throw new Error(`cannot begin turn from ${this.phase}`);
|
|
39
|
+
if (this.finalizedTurnIds.has(turnId) || turnId.length === 0)
|
|
40
|
+
throw new Error('turnId must be unique and non-empty');
|
|
41
|
+
this.epoch += 1;
|
|
42
|
+
this.turnId = turnId;
|
|
43
|
+
this.providerTerminal = false;
|
|
44
|
+
this.phase = 'ACTIVE';
|
|
45
|
+
this.ledger.append(turnId, { type: 'turn.begun', epoch: this.epoch, provider });
|
|
46
|
+
}
|
|
47
|
+
receive(serverId, toolId, args) {
|
|
48
|
+
if (this.phase !== 'ACTIVE' || !this.turnId)
|
|
49
|
+
return { ok: false, code: 'TURN_NOT_ACTIVE' };
|
|
50
|
+
const tool = this.artifact.canonicalIr.states[this.state]?.tools[`${serverId}/${toolId}`];
|
|
51
|
+
if (!tool)
|
|
52
|
+
return { ok: false, code: 'SCENARIO_NO_MATCH' };
|
|
53
|
+
const invocationId = `invocation-${++this.receivedSeq}`;
|
|
54
|
+
const active = [...this.invocations.values()].filter((entry) => !['DELIVERED', 'REJECTED', 'ABORTED'].includes(entry.phase));
|
|
55
|
+
const replayRequestRef = this.replayVault.store(args);
|
|
56
|
+
const invocation = { id: invocationId, receivedSeq: this.receivedSeq, turnId: this.turnId, epoch: this.epoch, serverId, toolId, args, phase: 'RECEIVED', effectful: tool.effectful, stateBefore: this.state };
|
|
57
|
+
this.invocations.set(invocationId, invocation);
|
|
58
|
+
this.ledger.append(this.turnId, { type: 'mcp.invocation', invocationId, receivedSeq: invocation.receivedSeq, turnEpoch: invocation.epoch, serverId, toolId, argsMac: valueMac(this.macKey, args), replayRequestRef, snapshotSeq: this.committedSeq, stateBefore: this.state });
|
|
59
|
+
if (active.length > 0 && (tool.effectful || active.some((entry) => entry.effectful))) {
|
|
60
|
+
const ids = [...active.map((entry) => entry.id), invocationId].sort();
|
|
61
|
+
for (const entry of active)
|
|
62
|
+
if (entry.phase !== 'COMMITTED')
|
|
63
|
+
entry.phase = 'ABORTED';
|
|
64
|
+
invocation.phase = 'ABORTED';
|
|
65
|
+
this.phase = 'POISONED';
|
|
66
|
+
this.ledger.append(this.turnId, { type: 'domain.poisoned', reason: 'concurrent-effect', invocationIds: ids });
|
|
67
|
+
return { ok: false, code: 'SCENARIO_CONCURRENT_EFFECT', invocationId };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, value: { invocationId } };
|
|
70
|
+
}
|
|
71
|
+
match(invocationId) {
|
|
72
|
+
const invocation = this.requireInvocation(invocationId, 'RECEIVED');
|
|
73
|
+
const validator = this.validators.get(`${invocation.serverId}/${invocation.toolId}`);
|
|
74
|
+
if (!validator(invocation.args)) {
|
|
75
|
+
invocation.phase = 'REJECTED';
|
|
76
|
+
return { ok: false, code: 'SCENARIO_INVALID_ARGUMENTS', invocationId, issues: (validator.errors ?? []).map((error) => ({ instancePointer: error.instancePath, schemaPointer: error.schemaPath, keyword: error.keyword })) };
|
|
77
|
+
}
|
|
78
|
+
const table = this.artifact.canonicalIr.states[this.state].tools[`${invocation.serverId}/${invocation.toolId}`];
|
|
79
|
+
const matches = table.cases.filter((scenarioCase) => matchExpression(invocation.args, scenarioCase.when));
|
|
80
|
+
if (matches.length > 1) {
|
|
81
|
+
invocation.phase = 'REJECTED';
|
|
82
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.decision', invocationId, authority: 'scenario-host', outcome: 'ambiguous' });
|
|
83
|
+
this.poison('ambiguous-decision', [invocationId]);
|
|
84
|
+
return { ok: false, code: 'SCENARIO_AMBIGUOUS', invocationId };
|
|
85
|
+
}
|
|
86
|
+
if (matches.length === 0) {
|
|
87
|
+
invocation.phase = 'REJECTED';
|
|
88
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.decision', invocationId, authority: 'scenario-host', outcome: 'no-match' });
|
|
89
|
+
return { ok: false, code: table.otherwise.code, message: table.otherwise.message, invocationId };
|
|
90
|
+
}
|
|
91
|
+
invocation.selected = matches[0];
|
|
92
|
+
invocation.phase = 'MATCHED';
|
|
93
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.decision', invocationId, authority: 'scenario-host', outcome: 'matched', caseId: matches[0].id });
|
|
94
|
+
if (matches[0].outcome.kind === 'tool-error') {
|
|
95
|
+
invocation.phase = 'REJECTED';
|
|
96
|
+
return { ok: true, value: { outcome: matches[0].outcome } };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, value: { outcome: matches[0].outcome } };
|
|
99
|
+
}
|
|
100
|
+
prepare(invocationId) {
|
|
101
|
+
const invocation = this.requireInvocation(invocationId, 'MATCHED');
|
|
102
|
+
if (invocation.selected?.outcome.kind !== 'success')
|
|
103
|
+
return { ok: false, code: 'SCENARIO_NO_MATCH', invocationId };
|
|
104
|
+
invocation.phase = 'PREPARED';
|
|
105
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.prepared', invocationId, turnEpoch: invocation.epoch });
|
|
106
|
+
return { ok: true, value: undefined };
|
|
107
|
+
}
|
|
108
|
+
commit(invocationId) {
|
|
109
|
+
const current = this.invocations.get(invocationId);
|
|
110
|
+
if (current?.phase === 'ABORTED')
|
|
111
|
+
return { ok: false, code: 'SCENARIO_CANCELLED', invocationId };
|
|
112
|
+
const invocation = this.requireInvocation(invocationId, 'PREPARED');
|
|
113
|
+
if (this.phase !== 'ACTIVE' || invocation.epoch !== this.epoch || invocation.turnId !== this.turnId) {
|
|
114
|
+
invocation.phase = 'ABORTED';
|
|
115
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.abort', invocationId, phase: 'prepared', reason: 'stale-turn-epoch' });
|
|
116
|
+
return { ok: false, code: 'STALE_TURN_EPOCH', invocationId };
|
|
117
|
+
}
|
|
118
|
+
if (this.state !== invocation.stateBefore) {
|
|
119
|
+
this.poison('commit-state-cas-failed', [invocationId]);
|
|
120
|
+
return { ok: false, code: 'SCENARIO_CONCURRENT_EFFECT', invocationId };
|
|
121
|
+
}
|
|
122
|
+
const selected = invocation.selected;
|
|
123
|
+
const stateAfter = selected.transition?.to ?? this.state;
|
|
124
|
+
this.state = stateAfter;
|
|
125
|
+
invocation.phase = 'COMMITTED';
|
|
126
|
+
this.committedSeq += 1;
|
|
127
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.commit', invocationId, committedEpoch: invocation.epoch, stateBefore: invocation.stateBefore, stateAfter, outcomeMac: valueMac(this.macKey, selected.outcome) });
|
|
128
|
+
return { ok: true, value: { committedSeq: this.committedSeq, outcome: selected.outcome } };
|
|
129
|
+
}
|
|
130
|
+
delivery(invocationId, status) {
|
|
131
|
+
const invocation = this.requireInvocation(invocationId, 'COMMITTED');
|
|
132
|
+
invocation.phase = status === 'unknown' ? 'DELIVERY_UNKNOWN' : 'DELIVERED';
|
|
133
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.delivery', invocationId, status });
|
|
134
|
+
if (status === 'unknown') {
|
|
135
|
+
this.poison('delivery-unknown', [invocationId]);
|
|
136
|
+
return { ok: false, code: 'SCENARIO_DELIVERY_UNKNOWN', invocationId };
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, value: undefined };
|
|
139
|
+
}
|
|
140
|
+
fence() {
|
|
141
|
+
if (this.phase !== 'ACTIVE' || !this.turnId)
|
|
142
|
+
throw new Error(`cannot fence from ${this.phase}`);
|
|
143
|
+
const previousEpoch = this.epoch;
|
|
144
|
+
this.epoch += 1;
|
|
145
|
+
this.phase = 'FENCING';
|
|
146
|
+
this.ledger.append(this.turnId, { type: 'turn.fenced', previousEpoch, nextEpoch: this.epoch });
|
|
147
|
+
}
|
|
148
|
+
abortUncommitted(reason = 'cancelled') {
|
|
149
|
+
if (this.phase !== 'FENCING' && this.phase !== 'POISONED')
|
|
150
|
+
throw new Error(`cannot abort from ${this.phase}`);
|
|
151
|
+
for (const invocation of this.invocations.values()) {
|
|
152
|
+
if (invocation.turnId !== this.turnId || !['RECEIVED', 'MATCHED', 'PREPARED'].includes(invocation.phase))
|
|
153
|
+
continue;
|
|
154
|
+
const phase = invocation.phase === 'RECEIVED' ? 'received' : invocation.phase === 'MATCHED' ? 'matched' : 'prepared';
|
|
155
|
+
invocation.phase = 'ABORTED';
|
|
156
|
+
this.ledger.append(invocation.turnId, { type: 'mcp.abort', invocationId: invocation.id, phase, reason });
|
|
157
|
+
}
|
|
158
|
+
if (this.phase === 'FENCING')
|
|
159
|
+
this.phase = 'QUIESCING';
|
|
160
|
+
}
|
|
161
|
+
observeProviderTerminal() { this.providerTerminal = true; }
|
|
162
|
+
finalize(status, reason) {
|
|
163
|
+
if (!this.turnId || this.finalizedTurnIds.has(this.turnId))
|
|
164
|
+
return false;
|
|
165
|
+
const active = [...this.invocations.values()].some((entry) => entry.turnId === this.turnId && !['DELIVERED', 'REJECTED', 'ABORTED', 'DELIVERY_UNKNOWN'].includes(entry.phase));
|
|
166
|
+
if (status === 'cancelled-clean' && (!this.providerTerminal || active || this.phase !== 'QUIESCING'))
|
|
167
|
+
throw new Error('cancellation cannot finalize before provider terminal and host quiescence');
|
|
168
|
+
if (status === 'settled' && (active || this.phase !== 'ACTIVE'))
|
|
169
|
+
throw new Error('turn is not settled');
|
|
170
|
+
if (this.phase === 'POISONED')
|
|
171
|
+
status = 'poisoned';
|
|
172
|
+
this.finalizedTurnIds.add(this.turnId);
|
|
173
|
+
if (reason)
|
|
174
|
+
this.ledger.append(this.turnId, { type: 'turn.finalized', status, reason, state: this.state });
|
|
175
|
+
else
|
|
176
|
+
this.ledger.append(this.turnId, { type: 'turn.finalized', status, state: this.state });
|
|
177
|
+
if (status !== 'poisoned')
|
|
178
|
+
this.phase = 'READY';
|
|
179
|
+
else
|
|
180
|
+
this.phase = 'POISONED';
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
dispose() {
|
|
184
|
+
if (this.phase === 'DISPOSED')
|
|
185
|
+
return;
|
|
186
|
+
if (this.phase === 'ACTIVE')
|
|
187
|
+
this.fence();
|
|
188
|
+
if (this.phase === 'FENCING')
|
|
189
|
+
this.abortUncommitted('dispose');
|
|
190
|
+
this.phase = 'DISPOSING';
|
|
191
|
+
this.invocations.clear();
|
|
192
|
+
this.phase = 'DISPOSED';
|
|
193
|
+
}
|
|
194
|
+
activeInvocationCount() {
|
|
195
|
+
return [...this.invocations.values()].filter((entry) => !['DELIVERED', 'REJECTED', 'ABORTED', 'DELIVERY_UNKNOWN'].includes(entry.phase)).length;
|
|
196
|
+
}
|
|
197
|
+
transition(from, to) {
|
|
198
|
+
if (this.phase !== from)
|
|
199
|
+
throw new Error(`expected ${from}, got ${this.phase}`);
|
|
200
|
+
this.phase = to;
|
|
201
|
+
}
|
|
202
|
+
requireInvocation(id, phase) {
|
|
203
|
+
const invocation = this.invocations.get(id);
|
|
204
|
+
if (!invocation || invocation.phase !== phase)
|
|
205
|
+
throw new Error(`invocation ${id} is not ${phase}`);
|
|
206
|
+
return invocation;
|
|
207
|
+
}
|
|
208
|
+
poison(reason, invocationIds) {
|
|
209
|
+
if (!this.turnId || this.phase === 'POISONED')
|
|
210
|
+
return;
|
|
211
|
+
this.phase = 'POISONED';
|
|
212
|
+
this.ledger.append(this.turnId, { type: 'domain.poisoned', reason, invocationIds: [...invocationIds].sort() });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { JsonValue } from './types.js';
|
|
2
|
+
export declare function assertJsonValue(value: unknown, path?: string, ancestors?: Set<object>): asserts value is JsonValue;
|
|
3
|
+
export declare function canonicalJson(value: unknown): string;
|
|
4
|
+
export declare function sha256Canonical(value: unknown): string;
|
|
5
|
+
export declare function escapePointerToken(token: string): string;
|
|
6
|
+
export declare function parsePointer(pointer: string): string[];
|
|
7
|
+
export declare const MISSING: unique symbol;
|
|
8
|
+
export declare function resolvePointer(value: JsonValue, pointer: string): JsonValue | typeof MISSING;
|
|
9
|
+
export declare function parseStrictJson(text: string): JsonValue;
|