@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,201 @@
|
|
|
1
|
+
import { collectSensitiveEnvValues } from '../../tool-event-results.js';
|
|
2
|
+
import { scanBearerSurfaces } from './cleanup.js';
|
|
3
|
+
import { materializeProjection } from './materialize.js';
|
|
4
|
+
import { projectEndpoints } from './projection.js';
|
|
5
|
+
import { admitRuntimeLock } from './runtime-lock.js';
|
|
6
|
+
import { spawnAcpClient } from './acp-client.js';
|
|
7
|
+
import { projectAcpAuthorTurn, } from './acp-author-projector.js';
|
|
8
|
+
export async function createAcpAuthorSession(options) {
|
|
9
|
+
const sessionId = await options.client.newSession(options.mcpServers);
|
|
10
|
+
let activeTerminal;
|
|
11
|
+
let interruptSent = false;
|
|
12
|
+
let promptStarted = false;
|
|
13
|
+
let closed = false;
|
|
14
|
+
let closePromise;
|
|
15
|
+
const waitForTerminal = async () => { await activeTerminal; };
|
|
16
|
+
const interrupt = async () => {
|
|
17
|
+
if (!activeTerminal || interruptSent)
|
|
18
|
+
return;
|
|
19
|
+
interruptSent = true;
|
|
20
|
+
options.client.setCancelling(true);
|
|
21
|
+
if (promptStarted)
|
|
22
|
+
options.client.cancel(sessionId);
|
|
23
|
+
};
|
|
24
|
+
const session = {
|
|
25
|
+
async runTurn(message, turnNumber) {
|
|
26
|
+
if (closed)
|
|
27
|
+
throw new Error('ACP author session is closed');
|
|
28
|
+
if (activeTerminal)
|
|
29
|
+
throw new Error('ACP author session already has an active turn');
|
|
30
|
+
const checkpoint = options.client.notificationCheckpoint();
|
|
31
|
+
interruptSent = false;
|
|
32
|
+
let finishTerminal;
|
|
33
|
+
const terminal = new Promise((resolve) => { finishTerminal = resolve; });
|
|
34
|
+
activeTerminal = terminal;
|
|
35
|
+
let checkpointDrained = false;
|
|
36
|
+
try {
|
|
37
|
+
await options.assertProtectedSurfacesClean?.();
|
|
38
|
+
if (interruptSent)
|
|
39
|
+
throw new Error('ACP author session turn cancelled before prompt');
|
|
40
|
+
promptStarted = true;
|
|
41
|
+
const promptResult = await options.client.prompt(sessionId, message);
|
|
42
|
+
const notifications = options.client.drainNotifications(checkpoint);
|
|
43
|
+
checkpointDrained = true;
|
|
44
|
+
return projectAcpAuthorTurn({
|
|
45
|
+
provider: options.provider,
|
|
46
|
+
terminal: promptResult,
|
|
47
|
+
notifications,
|
|
48
|
+
normalizeToolName: options.normalizeToolName,
|
|
49
|
+
protectedValues: options.protectedValues,
|
|
50
|
+
sensitiveValues: options.sensitiveValues,
|
|
51
|
+
turnNumber,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
if (!checkpointDrained)
|
|
56
|
+
options.client.drainNotifications(checkpoint);
|
|
57
|
+
promptStarted = false;
|
|
58
|
+
options.client.setCancelling(false);
|
|
59
|
+
finishTerminal();
|
|
60
|
+
if (activeTerminal === terminal)
|
|
61
|
+
activeTerminal = undefined;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
interrupt,
|
|
65
|
+
waitForTerminal,
|
|
66
|
+
close() {
|
|
67
|
+
closePromise ??= (async () => {
|
|
68
|
+
if (closed)
|
|
69
|
+
return;
|
|
70
|
+
closed = true;
|
|
71
|
+
const terminal = activeTerminal;
|
|
72
|
+
if (terminal) {
|
|
73
|
+
await interrupt();
|
|
74
|
+
if (!await settlesWithin(terminal, options.closeJoinTimeoutMs ?? 2_000)) {
|
|
75
|
+
await options.client.close();
|
|
76
|
+
}
|
|
77
|
+
await terminal;
|
|
78
|
+
}
|
|
79
|
+
await options.client.close();
|
|
80
|
+
})();
|
|
81
|
+
return closePromise;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return session;
|
|
85
|
+
}
|
|
86
|
+
export function createAcpScenarioAttachment(session) {
|
|
87
|
+
return {
|
|
88
|
+
value: session,
|
|
89
|
+
interrupt: async (_turn, reason) => session.interrupt(reason),
|
|
90
|
+
waitForTerminal: async () => session.waitForTerminal(),
|
|
91
|
+
close: async () => session.close(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* P4/P5 provider seam: attach one exact, relocatable ACP runtime to the
|
|
96
|
+
* coordinator and return the reusable author session as its opaque value.
|
|
97
|
+
*/
|
|
98
|
+
export async function attachAcpAuthorSession(options) {
|
|
99
|
+
const protectedValues = [...new Set(options.context.endpoints.flatMap((endpoint) => [
|
|
100
|
+
endpoint.url,
|
|
101
|
+
...endpoint.headers.flatMap((header) => {
|
|
102
|
+
const value = options.context.resolveSecret(header.valueRef);
|
|
103
|
+
return [value, value.startsWith('Bearer ') ? value.slice('Bearer '.length) : value];
|
|
104
|
+
}),
|
|
105
|
+
]).filter((value) => value.length > 0))];
|
|
106
|
+
const createClient = options.createClient ?? spawnAcpClient;
|
|
107
|
+
const sensitiveValues = collectSensitiveEnvValues(Object.fromEntries(Object.entries(options.environment).filter((entry) => typeof entry[1] === 'string')));
|
|
108
|
+
let client;
|
|
109
|
+
const admission = await admitRuntimeLock(options.runtimeLock, {
|
|
110
|
+
provider: options.provider,
|
|
111
|
+
platform: process.platform,
|
|
112
|
+
architecture: process.arch,
|
|
113
|
+
resolvedExecutable: options.resolvedExecutable,
|
|
114
|
+
environment: options.environment,
|
|
115
|
+
forbiddenSecrets: protectedValues,
|
|
116
|
+
async initializeControlPlane(commandPath, args) {
|
|
117
|
+
const initialized = createClient({
|
|
118
|
+
provider: options.provider,
|
|
119
|
+
command: commandPath,
|
|
120
|
+
args,
|
|
121
|
+
cwd: options.cwd,
|
|
122
|
+
environment: options.environment,
|
|
123
|
+
protectedValues,
|
|
124
|
+
});
|
|
125
|
+
client = initialized;
|
|
126
|
+
try {
|
|
127
|
+
const normalizedHandshake = await initialized.initialize();
|
|
128
|
+
return {
|
|
129
|
+
normalizedHandshake,
|
|
130
|
+
capabilities: options.runtimeLock.capabilities,
|
|
131
|
+
close: () => initialized.close(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
await initialized.close().catch(() => undefined);
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const admittedClient = client;
|
|
141
|
+
if (!admittedClient)
|
|
142
|
+
throw new Error('RUNTIME_NOT_ADMITTED: ACP client unavailable');
|
|
143
|
+
try {
|
|
144
|
+
const projected = projectEndpoints(options.context.endpoints, admission.profile);
|
|
145
|
+
if (!Array.isArray(projected))
|
|
146
|
+
throw new Error(`RUNTIME_NOT_ADMITTED: ${projected.reason}`);
|
|
147
|
+
const mcpServers = projected.flatMap((plan) => {
|
|
148
|
+
const materialized = materializeProjection(plan, { resolve: options.context.resolveSecret });
|
|
149
|
+
if (materialized.kind !== 'acp-session' || materialized.provider !== options.provider) {
|
|
150
|
+
throw new Error('RUNTIME_NOT_ADMITTED: ACP projection materialized for another provider');
|
|
151
|
+
}
|
|
152
|
+
return materialized.mcpServers.map((server) => ({
|
|
153
|
+
type: server.type,
|
|
154
|
+
name: server.name,
|
|
155
|
+
url: server.url,
|
|
156
|
+
headers: server.headers.map((header) => ({ ...header })),
|
|
157
|
+
}));
|
|
158
|
+
});
|
|
159
|
+
const assertProtectedSurfacesClean = async () => {
|
|
160
|
+
for (const value of protectedValues) {
|
|
161
|
+
const scans = await scanBearerSurfaces(options.scanRoots, value);
|
|
162
|
+
if (scans.some((entry) => entry.status !== 'clean')) {
|
|
163
|
+
throw new Error('ACP protected projection reached a writable or unreadable surface');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (admittedClient.inboundSecretSeen())
|
|
167
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
168
|
+
};
|
|
169
|
+
const session = await createAcpAuthorSession({
|
|
170
|
+
provider: options.provider,
|
|
171
|
+
client: admittedClient,
|
|
172
|
+
mcpServers,
|
|
173
|
+
normalizeToolName: options.normalizeToolName,
|
|
174
|
+
protectedValues,
|
|
175
|
+
sensitiveValues,
|
|
176
|
+
assertProtectedSurfacesClean,
|
|
177
|
+
});
|
|
178
|
+
if (admittedClient.controlPlaneSecretFrames() !== 1 || admittedClient.inboundSecretSeen()) {
|
|
179
|
+
throw new Error('RUNTIME_NOT_ADMITTED: ACP secret isolation failed during session admission');
|
|
180
|
+
}
|
|
181
|
+
await assertProtectedSurfacesClean();
|
|
182
|
+
return createAcpScenarioAttachment(session);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
try {
|
|
186
|
+
await admission.controlPlane.close();
|
|
187
|
+
}
|
|
188
|
+
catch (cleanupError) {
|
|
189
|
+
throw new AggregateError([error, cleanupError], 'ACP author-session setup and cleanup failed');
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function settlesWithin(promise, timeoutMs) {
|
|
195
|
+
let timer;
|
|
196
|
+
return Promise.race([
|
|
197
|
+
promise.then(() => true),
|
|
198
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
|
|
199
|
+
]).finally(() => { if (timer)
|
|
200
|
+
clearTimeout(timer); });
|
|
201
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import type { JsonObject, JsonValue } from './types.js';
|
|
3
|
+
export type JsonRpcMessage = JsonObject;
|
|
4
|
+
export type AcpNotificationCheckpoint = number;
|
|
5
|
+
export type AcpPermissionRequest = {
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
toolCallId?: string;
|
|
8
|
+
options: readonly {
|
|
9
|
+
optionId: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
}[];
|
|
12
|
+
};
|
|
13
|
+
export type AcpClientRequestHandler = (method: string, params: JsonValue) => Promise<JsonValue>;
|
|
14
|
+
export declare class AcpClientCore {
|
|
15
|
+
private readonly sendFrame;
|
|
16
|
+
private readonly onRequest;
|
|
17
|
+
private nextId;
|
|
18
|
+
private readonly pending;
|
|
19
|
+
private readonly notifications;
|
|
20
|
+
private nextNotificationSequence;
|
|
21
|
+
private closed;
|
|
22
|
+
private closeReason;
|
|
23
|
+
constructor(sendFrame: (message: JsonRpcMessage) => void, onRequest: AcpClientRequestHandler);
|
|
24
|
+
request(method: string, params: JsonObject, timeoutMs?: number): Promise<JsonValue>;
|
|
25
|
+
notify(method: string, params: JsonObject): void;
|
|
26
|
+
receiveLine(line: string): Promise<void>;
|
|
27
|
+
observedNotifications(): readonly JsonRpcMessage[];
|
|
28
|
+
notificationCheckpoint(): AcpNotificationCheckpoint;
|
|
29
|
+
drainNotifications(checkpoint: AcpNotificationCheckpoint): readonly JsonRpcMessage[];
|
|
30
|
+
close(reason?: string): void;
|
|
31
|
+
}
|
|
32
|
+
export type AcpNormalizedHandshake = JsonObject & {
|
|
33
|
+
normalizerVersion: 'pathgrade-acp-v1-capabilities-v1';
|
|
34
|
+
provider: 'cursor' | 'opencode';
|
|
35
|
+
protocolVersion: 1;
|
|
36
|
+
httpMcp: true;
|
|
37
|
+
sessionClose: boolean;
|
|
38
|
+
};
|
|
39
|
+
export type AcpPromptResult = {
|
|
40
|
+
stopReason: string;
|
|
41
|
+
};
|
|
42
|
+
export type AcpClient = {
|
|
43
|
+
initialize(): Promise<AcpNormalizedHandshake>;
|
|
44
|
+
newSession(mcpServers: readonly JsonObject[]): Promise<string>;
|
|
45
|
+
prompt(sessionId: string, text: string): Promise<AcpPromptResult>;
|
|
46
|
+
cancel(sessionId: string): void;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
setCancelling(value: boolean): void;
|
|
49
|
+
isOpen(): boolean;
|
|
50
|
+
controlPlaneSecretFrames(): number;
|
|
51
|
+
inboundSecretSeen(): boolean;
|
|
52
|
+
notificationCheckpoint(): AcpNotificationCheckpoint;
|
|
53
|
+
drainNotifications(checkpoint: AcpNotificationCheckpoint): readonly JsonRpcMessage[];
|
|
54
|
+
notifications(): readonly JsonRpcMessage[];
|
|
55
|
+
};
|
|
56
|
+
export type SpawnAcpClientOptions = {
|
|
57
|
+
provider: 'cursor' | 'opencode';
|
|
58
|
+
command: string;
|
|
59
|
+
args: readonly string[];
|
|
60
|
+
cwd: string;
|
|
61
|
+
environment: NodeJS.ProcessEnv;
|
|
62
|
+
protectedValues: readonly string[];
|
|
63
|
+
spawnProcess?: SpawnAcpProcess;
|
|
64
|
+
};
|
|
65
|
+
export type SpawnAcpProcess = (command: string, args: readonly string[], options: {
|
|
66
|
+
cwd: string;
|
|
67
|
+
env: NodeJS.ProcessEnv;
|
|
68
|
+
stdio: ['pipe', 'pipe', 'pipe'];
|
|
69
|
+
}) => ChildProcessWithoutNullStreams;
|
|
70
|
+
export declare function spawnAcpClient(options: SpawnAcpClientOptions): AcpClient;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- ACP frames are an external JSON-RPC trust boundary. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
function isJsonObject(value) {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function jsonValue(value) {
|
|
8
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
11
|
+
return value;
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.map(jsonValue);
|
|
14
|
+
if (value !== null && typeof value === 'object')
|
|
15
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonValue(entry)]));
|
|
16
|
+
throw new Error('ACP response was not JSON');
|
|
17
|
+
}
|
|
18
|
+
function jsonObject(value) {
|
|
19
|
+
const parsed = jsonValue(value);
|
|
20
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
21
|
+
throw new Error('ACP frame was not a JSON object');
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
function errorFromResponse(method, value) {
|
|
25
|
+
if (!isJsonObject(value))
|
|
26
|
+
return new Error(`${method} failed with an invalid JSON-RPC error`);
|
|
27
|
+
const rawMessage = value.message;
|
|
28
|
+
const code = value.code;
|
|
29
|
+
const message = typeof rawMessage === 'string' ? rawMessage : 'unknown ACP error';
|
|
30
|
+
return Object.assign(new Error(`${method} failed: ${message}`), {
|
|
31
|
+
code: typeof code === 'number' || typeof code === 'string' ? code : undefined,
|
|
32
|
+
data: value.data,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export class AcpClientCore {
|
|
36
|
+
sendFrame;
|
|
37
|
+
onRequest;
|
|
38
|
+
nextId = 1;
|
|
39
|
+
pending = new Map();
|
|
40
|
+
notifications = [];
|
|
41
|
+
nextNotificationSequence = 0;
|
|
42
|
+
closed = false;
|
|
43
|
+
closeReason = 'ACP client is closed';
|
|
44
|
+
constructor(sendFrame, onRequest) {
|
|
45
|
+
this.sendFrame = sendFrame;
|
|
46
|
+
this.onRequest = onRequest;
|
|
47
|
+
}
|
|
48
|
+
request(method, params, timeoutMs = 120_000) {
|
|
49
|
+
if (this.closed)
|
|
50
|
+
return Promise.reject(new Error(this.closeReason));
|
|
51
|
+
const id = this.nextId++;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
this.pending.delete(id);
|
|
55
|
+
reject(new Error(`ACP ${method} timed out after ${timeoutMs}ms`));
|
|
56
|
+
}, timeoutMs);
|
|
57
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
58
|
+
this.sendFrame({ jsonrpc: '2.0', id, method, params });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
notify(method, params) {
|
|
62
|
+
if (this.closed)
|
|
63
|
+
throw new Error(this.closeReason);
|
|
64
|
+
this.sendFrame({ jsonrpc: '2.0', method, params });
|
|
65
|
+
}
|
|
66
|
+
async receiveLine(line) {
|
|
67
|
+
let message;
|
|
68
|
+
try {
|
|
69
|
+
message = jsonObject(JSON.parse(line));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error('ACP emitted a non-JSON frame');
|
|
73
|
+
}
|
|
74
|
+
if (message.jsonrpc !== '2.0')
|
|
75
|
+
throw new Error('ACP emitted an invalid JSON-RPC frame');
|
|
76
|
+
const rawId = message.id;
|
|
77
|
+
const id = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : undefined;
|
|
78
|
+
const rawMethod = message.method;
|
|
79
|
+
if (id !== undefined && rawMethod === undefined) {
|
|
80
|
+
const pending = this.pending.get(id);
|
|
81
|
+
if (!pending)
|
|
82
|
+
return;
|
|
83
|
+
this.pending.delete(id);
|
|
84
|
+
clearTimeout(pending.timer);
|
|
85
|
+
const responseError = message.error;
|
|
86
|
+
if (responseError !== undefined)
|
|
87
|
+
pending.reject(errorFromResponse(pending.method, responseError));
|
|
88
|
+
else
|
|
89
|
+
pending.resolve(message.result ?? null);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (typeof rawMethod !== 'string')
|
|
93
|
+
throw new Error('ACP JSON-RPC frame has no method');
|
|
94
|
+
if (id === undefined) {
|
|
95
|
+
this.notifications.push({ sequence: this.nextNotificationSequence++, message: jsonObject(message) });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.onRequest(rawMethod, message.params ?? null);
|
|
100
|
+
this.sendFrame({ jsonrpc: '2.0', id, result });
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.sendFrame({ jsonrpc: '2.0', id, error: { code: -32601, message: error instanceof Error ? error.message : String(error) } });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
observedNotifications() {
|
|
107
|
+
return this.notifications.map(({ message }) => ({ ...message }));
|
|
108
|
+
}
|
|
109
|
+
notificationCheckpoint() {
|
|
110
|
+
return this.nextNotificationSequence;
|
|
111
|
+
}
|
|
112
|
+
drainNotifications(checkpoint) {
|
|
113
|
+
if (!Number.isSafeInteger(checkpoint) || checkpoint < 0 || checkpoint > this.nextNotificationSequence) {
|
|
114
|
+
throw new Error('invalid ACP notification checkpoint');
|
|
115
|
+
}
|
|
116
|
+
const drained = [];
|
|
117
|
+
for (let index = this.notifications.length - 1; index >= 0; index--) {
|
|
118
|
+
const entry = this.notifications[index];
|
|
119
|
+
if (entry.sequence < checkpoint)
|
|
120
|
+
continue;
|
|
121
|
+
drained.unshift({ ...entry.message });
|
|
122
|
+
this.notifications.splice(index, 1);
|
|
123
|
+
}
|
|
124
|
+
return drained;
|
|
125
|
+
}
|
|
126
|
+
close(reason = 'ACP client closed') {
|
|
127
|
+
if (this.closed)
|
|
128
|
+
return;
|
|
129
|
+
this.closed = true;
|
|
130
|
+
this.closeReason = reason;
|
|
131
|
+
for (const request of this.pending.values()) {
|
|
132
|
+
clearTimeout(request.timer);
|
|
133
|
+
request.reject(new Error(reason));
|
|
134
|
+
}
|
|
135
|
+
this.pending.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function normalizeHandshake(provider, value) {
|
|
139
|
+
if (!isJsonObject(value) || value.protocolVersion !== 1 || !isJsonObject(value.agentCapabilities))
|
|
140
|
+
throw new Error('ACP initialize did not return protocol v1 capabilities');
|
|
141
|
+
const mcp = value.agentCapabilities.mcpCapabilities;
|
|
142
|
+
if (!isJsonObject(mcp) || mcp.http !== true)
|
|
143
|
+
throw new Error('ACP initialize did not advertise HTTP MCP');
|
|
144
|
+
const session = value.agentCapabilities.sessionCapabilities;
|
|
145
|
+
return {
|
|
146
|
+
normalizerVersion: 'pathgrade-acp-v1-capabilities-v1',
|
|
147
|
+
provider,
|
|
148
|
+
protocolVersion: 1,
|
|
149
|
+
httpMcp: true,
|
|
150
|
+
sessionClose: session !== undefined && isJsonObject(session) && session.close !== undefined && isJsonObject(session.close),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function readSessionId(value) {
|
|
154
|
+
const sessionId = isJsonObject(value) ? value.sessionId : undefined;
|
|
155
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
156
|
+
throw new Error('ACP session/new returned no sessionId');
|
|
157
|
+
return sessionId;
|
|
158
|
+
}
|
|
159
|
+
function readPromptResult(value) {
|
|
160
|
+
const stopReason = isJsonObject(value) ? value.stopReason : undefined;
|
|
161
|
+
if (typeof stopReason !== 'string')
|
|
162
|
+
throw new Error('ACP session/prompt returned no stopReason');
|
|
163
|
+
return { stopReason };
|
|
164
|
+
}
|
|
165
|
+
function permissionRequest(params) {
|
|
166
|
+
if (!isJsonObject(params))
|
|
167
|
+
throw new Error('invalid ACP permission request');
|
|
168
|
+
const toolCall = params.toolCall !== undefined && isJsonObject(params.toolCall) ? params.toolCall : undefined;
|
|
169
|
+
const rawOptions = params.options;
|
|
170
|
+
if (!Array.isArray(rawOptions))
|
|
171
|
+
throw new Error('invalid ACP permission options');
|
|
172
|
+
const options = rawOptions.map((entry) => {
|
|
173
|
+
const optionId = isJsonObject(entry) ? entry.optionId : undefined;
|
|
174
|
+
const kind = isJsonObject(entry) ? entry.kind : undefined;
|
|
175
|
+
if (typeof optionId !== 'string' || typeof kind !== 'string')
|
|
176
|
+
throw new Error('invalid ACP permission option');
|
|
177
|
+
return { optionId, kind };
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
sessionId: typeof params.sessionId === 'string' ? params.sessionId : undefined,
|
|
181
|
+
toolCallId: toolCall && typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : undefined,
|
|
182
|
+
options,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function includesProtectedValue(value, protectedValues) {
|
|
186
|
+
return protectedValues.some((secret) => secret.length > 0 && value.includes(secret));
|
|
187
|
+
}
|
|
188
|
+
export function spawnAcpClient(options) {
|
|
189
|
+
const argv = [options.command, ...options.args].join('\u0000');
|
|
190
|
+
const environmentText = Object.entries(options.environment).map(([name, value]) => `${name}=${value ?? ''}`).join('\u0000');
|
|
191
|
+
if (includesProtectedValue(argv, options.protectedValues) || includesProtectedValue(environmentText, options.protectedValues)) {
|
|
192
|
+
throw new Error('ACP protected value reached child argv or environment');
|
|
193
|
+
}
|
|
194
|
+
const spawnProcess = options.spawnProcess ?? ((command, args, childOptions) => spawn(command, [...args], childOptions));
|
|
195
|
+
const child = spawnProcess(options.command, options.args, { cwd: options.cwd, env: options.environment, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
196
|
+
let open = true;
|
|
197
|
+
let cancelling = false;
|
|
198
|
+
let sessionClose = false;
|
|
199
|
+
let sessionId;
|
|
200
|
+
let outboundSecretFrames = 0;
|
|
201
|
+
let reflectedSecret = false;
|
|
202
|
+
const exit = new Promise((resolve) => {
|
|
203
|
+
child.once('exit', (code, signal) => {
|
|
204
|
+
open = false;
|
|
205
|
+
core.close(`ACP provider exited (${code ?? signal ?? 'unknown'})`);
|
|
206
|
+
resolve();
|
|
207
|
+
});
|
|
208
|
+
child.once('error', (error) => {
|
|
209
|
+
open = false;
|
|
210
|
+
core.close(`ACP provider failed: ${error.message}`);
|
|
211
|
+
resolve();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
const core = new AcpClientCore((message) => {
|
|
215
|
+
const frame = JSON.stringify(message);
|
|
216
|
+
if (includesProtectedValue(frame, options.protectedValues))
|
|
217
|
+
outboundSecretFrames += 1;
|
|
218
|
+
child.stdin.write(`${frame}\n`);
|
|
219
|
+
}, async (method, params) => {
|
|
220
|
+
if (method !== 'session/request_permission')
|
|
221
|
+
throw new Error(`unsupported ACP client request ${method}`);
|
|
222
|
+
const request = permissionRequest(params);
|
|
223
|
+
if (cancelling)
|
|
224
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
225
|
+
const allowed = request.options.find((entry) => entry.kind === 'allow_once');
|
|
226
|
+
if (!allowed)
|
|
227
|
+
throw new Error('ACP permission request has no allow_once option');
|
|
228
|
+
return { outcome: { outcome: 'selected', optionId: allowed.optionId } };
|
|
229
|
+
});
|
|
230
|
+
const lines = createInterface({ input: child.stdout });
|
|
231
|
+
lines.on('line', (line) => {
|
|
232
|
+
if (includesProtectedValue(line, options.protectedValues)) {
|
|
233
|
+
reflectedSecret = true;
|
|
234
|
+
core.close('ACP provider reflected a protected control-plane value');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
void core.receiveLine(line).catch((error) => core.close(error instanceof Error ? error.message : String(error)));
|
|
238
|
+
});
|
|
239
|
+
child.stderr.setEncoding('utf8');
|
|
240
|
+
child.stderr.on('data', (chunk) => {
|
|
241
|
+
if (includesProtectedValue(chunk, options.protectedValues)) {
|
|
242
|
+
reflectedSecret = true;
|
|
243
|
+
core.close('ACP provider reflected a protected control-plane value');
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
async initialize() {
|
|
248
|
+
const raw = await core.request('initialize', {
|
|
249
|
+
protocolVersion: 1,
|
|
250
|
+
clientCapabilities: {},
|
|
251
|
+
clientInfo: { name: 'pathgrade-direct-mcp', title: 'Pathgrade direct MCP', version: '2.0.0' },
|
|
252
|
+
}, 30_000);
|
|
253
|
+
const normalized = normalizeHandshake(options.provider, raw);
|
|
254
|
+
sessionClose = normalized.sessionClose;
|
|
255
|
+
return normalized;
|
|
256
|
+
},
|
|
257
|
+
async newSession(mcpServers) {
|
|
258
|
+
sessionId = readSessionId(await core.request('session/new', { cwd: options.cwd, mcpServers: [...mcpServers] }, 30_000));
|
|
259
|
+
return sessionId;
|
|
260
|
+
},
|
|
261
|
+
async prompt(currentSessionId, text) {
|
|
262
|
+
return readPromptResult(await core.request('session/prompt', { sessionId: currentSessionId, prompt: [{ type: 'text', text }] }, 180_000));
|
|
263
|
+
},
|
|
264
|
+
cancel(currentSessionId) { core.notify('session/cancel', { sessionId: currentSessionId }); },
|
|
265
|
+
async close() {
|
|
266
|
+
if (!open) {
|
|
267
|
+
await exit;
|
|
268
|
+
if (reflectedSecret)
|
|
269
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (sessionId && sessionClose)
|
|
273
|
+
await core.request('session/close', { sessionId }, 10_000).catch(() => undefined);
|
|
274
|
+
core.close();
|
|
275
|
+
lines.close();
|
|
276
|
+
child.stdin.end();
|
|
277
|
+
if (await waitForExit(exit, 1_000)) {
|
|
278
|
+
if (reflectedSecret)
|
|
279
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
child.kill('SIGTERM');
|
|
283
|
+
if (await waitForExit(exit, 2_000)) {
|
|
284
|
+
if (reflectedSecret)
|
|
285
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
child.kill('SIGKILL');
|
|
289
|
+
if (!await waitForExit(exit, 2_000))
|
|
290
|
+
throw new Error('ACP provider did not exit after SIGKILL');
|
|
291
|
+
if (reflectedSecret)
|
|
292
|
+
throw new Error('ACP provider reflected a protected control-plane value');
|
|
293
|
+
},
|
|
294
|
+
setCancelling(value) { cancelling = value; },
|
|
295
|
+
isOpen: () => open,
|
|
296
|
+
controlPlaneSecretFrames: () => outboundSecretFrames,
|
|
297
|
+
inboundSecretSeen: () => reflectedSecret,
|
|
298
|
+
notificationCheckpoint: () => core.notificationCheckpoint(),
|
|
299
|
+
drainNotifications: (checkpoint) => core.drainNotifications(checkpoint),
|
|
300
|
+
notifications: () => core.observedNotifications(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async function waitForExit(exit, timeoutMs) {
|
|
304
|
+
let timer;
|
|
305
|
+
return Promise.race([
|
|
306
|
+
exit.then(() => true),
|
|
307
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
|
|
308
|
+
]).finally(() => { if (timer)
|
|
309
|
+
clearTimeout(timer); });
|
|
310
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawnAcpClient } from './acp-client.js';
|
|
2
|
+
import { type CleanupReport } from './cleanup.js';
|
|
3
|
+
import type { EvidenceEnvelope, RuntimeCapabilityProfile, ScenarioArtifact, ScenarioMachineV2 } from './types.js';
|
|
4
|
+
export type AcpDirectMcpChecks = {
|
|
5
|
+
runtimeAdmitted: boolean;
|
|
6
|
+
secretFreeProject: boolean;
|
|
7
|
+
directCall: boolean;
|
|
8
|
+
structuredToolError: boolean;
|
|
9
|
+
fencedCancellation: boolean;
|
|
10
|
+
providerTerminal: boolean;
|
|
11
|
+
hostQuiescent: boolean;
|
|
12
|
+
postCancelReuse: boolean;
|
|
13
|
+
privateReplay: boolean;
|
|
14
|
+
cleanup: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type AcpDirectMcpTrialResult = {
|
|
17
|
+
selected: true;
|
|
18
|
+
profileFingerprint: string;
|
|
19
|
+
artifact: ScenarioArtifact;
|
|
20
|
+
evidence: readonly EvidenceEnvelope[];
|
|
21
|
+
checks: AcpDirectMcpChecks;
|
|
22
|
+
cleanup: CleanupReport;
|
|
23
|
+
cancellationStatus: string;
|
|
24
|
+
};
|
|
25
|
+
export type AcpDirectMcpOptions = {
|
|
26
|
+
provider: 'cursor' | 'opencode';
|
|
27
|
+
scenario: ScenarioMachineV2;
|
|
28
|
+
environment: NodeJS.ProcessEnv;
|
|
29
|
+
profile: RuntimeCapabilityProfile;
|
|
30
|
+
artifactDirectory?: string;
|
|
31
|
+
createClient?: typeof spawnAcpClient;
|
|
32
|
+
localHomeLinks?: readonly {
|
|
33
|
+
relativePath: string;
|
|
34
|
+
hostPath: string;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
export declare function curateAcpProcessEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
38
|
+
export declare function runAcpDirectMcpTrial(options: AcpDirectMcpOptions): Promise<AcpDirectMcpTrialResult>;
|