@modelprofile.com/mcp-crossharness 5.1.0 → 5.2.0
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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.codexappserverclient.d.ts +71 -0
- package/dist_ts/classes.codexappserverclient.js +298 -0
- package/dist_ts/classes.connection.codex.d.ts +1 -9
- package/dist_ts/classes.connection.codex.js +22 -246
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/package.json +2 -2
- package/readme.md +47 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.codexappserverclient.ts +331 -0
- package/ts/classes.connection.codex.ts +22 -256
- package/ts/index.ts +1 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as helpers from './helpers.js';
|
|
3
|
+
|
|
4
|
+
export type TCodexAppServerRequestId = string | number;
|
|
5
|
+
|
|
6
|
+
export interface ICodexAppServerNotification {
|
|
7
|
+
method: string;
|
|
8
|
+
params: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ICodexAppServerRequest extends ICodexAppServerNotification {
|
|
12
|
+
id: TCodexAppServerRequestId;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TCodexAppServerTransport =
|
|
17
|
+
| { type: 'websocket'; url: string; token?: string }
|
|
18
|
+
| { type: 'stdio'; readable: plugins.stream.Readable; writable: plugins.stream.Writable };
|
|
19
|
+
|
|
20
|
+
export interface ICodexAppServerClientOptions {
|
|
21
|
+
transport: TCodexAppServerTransport;
|
|
22
|
+
clientInfo: { name: string; title: string; version: string };
|
|
23
|
+
experimentalApi?: boolean;
|
|
24
|
+
/** Defaults to 4 MiB; consumers of paginated native histories may opt into up to 64 MiB. */
|
|
25
|
+
maxMessageBytes?: number;
|
|
26
|
+
onNotification?: (notificationArg: ICodexAppServerNotification) => void;
|
|
27
|
+
/** Replies use respond()/rejectRequest(); IDs are valid only on this connection. */
|
|
28
|
+
onServerRequest?: (requestArg: ICodexAppServerRequest) => void;
|
|
29
|
+
onClose?: (errorArg: Error) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface IPendingRequest {
|
|
33
|
+
resolve: (valueArg: unknown) => void;
|
|
34
|
+
reject: (errorArg: Error) => void;
|
|
35
|
+
timer: ReturnType<typeof setTimeout>;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
abortHandler?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A request failure is not evidence that a dispatched mutation did not execute. */
|
|
41
|
+
export class CodexAppServerRequestError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
messageArg: string,
|
|
44
|
+
public readonly dispatched: boolean,
|
|
45
|
+
public readonly code?: number,
|
|
46
|
+
optionsArg?: ErrorOptions,
|
|
47
|
+
) {
|
|
48
|
+
super(messageArg, optionsArg);
|
|
49
|
+
this.name = 'CodexAppServerRequestError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Connection-scoped app-server protocol client. Process and approval policy belong to the caller. */
|
|
54
|
+
export class CodexAppServerClient {
|
|
55
|
+
public state: 'connecting' | 'connected' | 'closed' = 'connecting';
|
|
56
|
+
public serverVersion?: string;
|
|
57
|
+
private socket?: InstanceType<typeof plugins.WsWebSocket>;
|
|
58
|
+
private started = false;
|
|
59
|
+
private requestCounter = 0;
|
|
60
|
+
private readonly pendingRequests = new Map<number, IPendingRequest>();
|
|
61
|
+
private readonly serverRequests = new Map<TCodexAppServerRequestId, AbortController>();
|
|
62
|
+
private readonly maxMessageBytes: number;
|
|
63
|
+
private inputBuffer = Buffer.alloc(0);
|
|
64
|
+
private readonly maxBufferedBytes = 4 * 1024 * 1024;
|
|
65
|
+
|
|
66
|
+
constructor(private readonly options: ICodexAppServerClientOptions) {
|
|
67
|
+
this.maxMessageBytes = options.maxMessageBytes ?? 4 * 1024 * 1024;
|
|
68
|
+
if (!Number.isSafeInteger(this.maxMessageBytes)
|
|
69
|
+
|| this.maxMessageBytes < 1024 || this.maxMessageBytes > 64 * 1024 * 1024) {
|
|
70
|
+
throw new Error('Codex maxMessageBytes must be between 1 KiB and 64 MiB');
|
|
71
|
+
}
|
|
72
|
+
if (options.transport.type === 'websocket') {
|
|
73
|
+
const url = new URL(options.transport.url);
|
|
74
|
+
if (!['ws:', 'wss:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
75
|
+
throw new Error('Codex app-server requires an explicit ws/wss URL without credentials or fragment');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public async connect(): Promise<void> {
|
|
81
|
+
if (this.started || this.state === 'closed') throw new Error('Codex client has already been started');
|
|
82
|
+
this.started = true;
|
|
83
|
+
try {
|
|
84
|
+
const transport = this.options.transport;
|
|
85
|
+
if (transport.type === 'websocket') {
|
|
86
|
+
const socket = new plugins.WsWebSocket(transport.url, {
|
|
87
|
+
perMessageDeflate: false,
|
|
88
|
+
followRedirects: false,
|
|
89
|
+
maxPayload: this.maxMessageBytes,
|
|
90
|
+
headers: transport.token ? { authorization: `Bearer ${transport.token}` } : {},
|
|
91
|
+
});
|
|
92
|
+
this.socket = socket;
|
|
93
|
+
socket.on('message', (data) => this.receive(data.toString()));
|
|
94
|
+
socket.on('error', this.handleError);
|
|
95
|
+
socket.on('close', this.handleEnd);
|
|
96
|
+
await new Promise<void>((resolve, reject) => {
|
|
97
|
+
const cleanup = () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
socket.off('open', opened);
|
|
100
|
+
socket.off('error', failed);
|
|
101
|
+
socket.off('close', closed);
|
|
102
|
+
};
|
|
103
|
+
const opened = () => { cleanup(); resolve(); };
|
|
104
|
+
const failed = (errorArg: Error) => { cleanup(); reject(errorArg); };
|
|
105
|
+
const closed = () => failed(new Error('Codex server WebSocket closed during handshake'));
|
|
106
|
+
const timer = setTimeout(() => failed(new Error('Codex server WebSocket handshake timed out')), 5000);
|
|
107
|
+
socket.once('open', opened);
|
|
108
|
+
socket.once('error', failed);
|
|
109
|
+
socket.once('close', closed);
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
if (transport.readable.destroyed || transport.writable.destroyed || transport.writable.writableEnded) {
|
|
113
|
+
throw new Error('Codex app-server streams are closed');
|
|
114
|
+
}
|
|
115
|
+
transport.readable.on('data', this.handleData);
|
|
116
|
+
transport.readable.on('error', this.handleError);
|
|
117
|
+
transport.readable.on('end', this.handleEnd);
|
|
118
|
+
transport.readable.on('close', this.handleEnd);
|
|
119
|
+
transport.writable.on('error', this.handleError);
|
|
120
|
+
transport.writable.on('close', this.handleEnd);
|
|
121
|
+
}
|
|
122
|
+
const initialized = await this.sendRequest('initialize', {
|
|
123
|
+
clientInfo: this.options.clientInfo,
|
|
124
|
+
capabilities: { experimentalApi: this.options.experimentalApi ?? false, requestAttestation: false },
|
|
125
|
+
}, 5000, undefined, true);
|
|
126
|
+
if (!helpers.isRecord(initialized) || typeof initialized.userAgent !== 'string') {
|
|
127
|
+
throw new Error('Codex server returned an invalid initialize response');
|
|
128
|
+
}
|
|
129
|
+
this.serverVersion = initialized.userAgent.match(/\/(\d+\.\d+\.\d+[^\s]*)/)?.[1];
|
|
130
|
+
this.sendFrame({ method: 'initialized' });
|
|
131
|
+
this.assertOpen();
|
|
132
|
+
this.state = 'connected';
|
|
133
|
+
} catch (error) {
|
|
134
|
+
this.close(helpers.errorMessage(error));
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public request(
|
|
140
|
+
methodArg: string,
|
|
141
|
+
paramsArg: unknown,
|
|
142
|
+
timeoutMsArg = 30000,
|
|
143
|
+
signalArg?: AbortSignal,
|
|
144
|
+
): Promise<unknown> {
|
|
145
|
+
return this.sendRequest(methodArg, paramsArg, timeoutMsArg, signalArg);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private sendRequest(
|
|
149
|
+
methodArg: string,
|
|
150
|
+
paramsArg: unknown,
|
|
151
|
+
timeoutMsArg: number,
|
|
152
|
+
signalArg?: AbortSignal,
|
|
153
|
+
allowInitializingArg = false,
|
|
154
|
+
): Promise<unknown> {
|
|
155
|
+
if ((!allowInitializingArg && this.state !== 'connected') || this.state === 'closed') {
|
|
156
|
+
return Promise.reject(new CodexAppServerRequestError('Codex client is not connected', false));
|
|
157
|
+
}
|
|
158
|
+
if (this.pendingRequests.size >= 256) {
|
|
159
|
+
return Promise.reject(new CodexAppServerRequestError('Codex connection has too many active requests', false));
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isFinite(timeoutMsArg) || timeoutMsArg <= 0 || timeoutMsArg > 2_147_483_647) {
|
|
162
|
+
return Promise.reject(new CodexAppServerRequestError('Codex request timeout is invalid', false));
|
|
163
|
+
}
|
|
164
|
+
if (signalArg?.aborted) return Promise.reject(this.cancellationError(signalArg, false));
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const id = ++this.requestCounter;
|
|
167
|
+
const timer = setTimeout(() => this.rejectPending(id,
|
|
168
|
+
new CodexAppServerRequestError(`Codex request timed out: ${methodArg}`, true)), timeoutMsArg);
|
|
169
|
+
const pending: IPendingRequest = { resolve, reject, timer, signal: signalArg };
|
|
170
|
+
if (signalArg) {
|
|
171
|
+
pending.abortHandler = () => this.rejectPending(id, this.cancellationError(signalArg, true));
|
|
172
|
+
signalArg.addEventListener('abort', pending.abortHandler, { once: true });
|
|
173
|
+
}
|
|
174
|
+
this.pendingRequests.set(id, pending);
|
|
175
|
+
try {
|
|
176
|
+
this.sendFrame({ id, method: methodArg, params: paramsArg });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
this.rejectPending(id, new CodexAppServerRequestError(helpers.errorMessage(error), false, undefined, { cause: error }));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public respond(idArg: TCodexAppServerRequestId, resultArg: unknown): boolean {
|
|
184
|
+
if (!this.finishServerRequest(idArg) || this.state === 'closed') return false;
|
|
185
|
+
this.sendFrame({ id: idArg, result: resultArg });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public rejectRequest(idArg: TCodexAppServerRequestId, codeArg: number, messageArg: string): boolean {
|
|
190
|
+
if (!this.finishServerRequest(idArg) || this.state === 'closed') return false;
|
|
191
|
+
this.sendFrame({ id: idArg, error: { code: codeArg, message: messageArg } });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public close(reasonArg = 'Codex server connection closed'): void {
|
|
196
|
+
if (this.state === 'closed') return;
|
|
197
|
+
this.state = 'closed';
|
|
198
|
+
const error = new CodexAppServerRequestError(reasonArg, true);
|
|
199
|
+
for (const id of this.pendingRequests.keys()) this.rejectPending(id, error);
|
|
200
|
+
for (const id of this.serverRequests.keys()) this.finishServerRequest(id);
|
|
201
|
+
this.inputBuffer = Buffer.alloc(0);
|
|
202
|
+
const transport = this.options.transport;
|
|
203
|
+
if (transport.type === 'websocket') {
|
|
204
|
+
if (this.socket && this.socket.readyState !== plugins.WsWebSocket.CLOSED) this.socket.terminate();
|
|
205
|
+
} else {
|
|
206
|
+
transport.readable.off('data', this.handleData);
|
|
207
|
+
transport.readable.off('end', this.handleEnd);
|
|
208
|
+
transport.readable.off('close', this.handleEnd);
|
|
209
|
+
transport.writable.off('close', this.handleEnd);
|
|
210
|
+
// End only these caller-supplied streams; the caller owns the subprocess.
|
|
211
|
+
transport.writable.end();
|
|
212
|
+
transport.readable.destroy();
|
|
213
|
+
}
|
|
214
|
+
this.options.onClose?.(error);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private readonly handleError = (errorArg: Error): void => { this.close(errorArg.message); };
|
|
218
|
+
private readonly handleEnd = (): void => { this.close('Codex server connection closed'); };
|
|
219
|
+
private readonly handleData = (dataArg: Buffer | string): void => {
|
|
220
|
+
const data = Buffer.isBuffer(dataArg) ? dataArg : Buffer.from(dataArg);
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < data.length && this.state !== 'closed') {
|
|
223
|
+
const newline = data.indexOf(10, offset);
|
|
224
|
+
const end = newline < 0 ? data.length : newline;
|
|
225
|
+
if (this.inputBuffer.length + end - offset > this.maxMessageBytes) {
|
|
226
|
+
this.close('Codex app-server frame exceeded the response limit');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.inputBuffer = Buffer.concat([this.inputBuffer, data.subarray(offset, end)]);
|
|
230
|
+
if (newline < 0) break;
|
|
231
|
+
const raw = this.inputBuffer.toString('utf8');
|
|
232
|
+
this.inputBuffer = Buffer.alloc(0);
|
|
233
|
+
if (raw.trim()) this.receive(raw);
|
|
234
|
+
offset = newline + 1;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
private sendFrame(frameArg: Record<string, unknown>): void {
|
|
239
|
+
const payload = JSON.stringify(frameArg);
|
|
240
|
+
const payloadBytes = Buffer.byteLength(payload, 'utf8');
|
|
241
|
+
const transport = this.options.transport;
|
|
242
|
+
const buffered = transport.type === 'websocket'
|
|
243
|
+
? this.socket?.bufferedAmount ?? 0 : transport.writable.writableLength;
|
|
244
|
+
if (this.state === 'closed') throw new Error('Codex server connection is closed');
|
|
245
|
+
if (buffered + payloadBytes + 1 > this.maxBufferedBytes) {
|
|
246
|
+
const error = new Error('Codex send buffer exceeded the 4 MiB limit');
|
|
247
|
+
this.close(error.message);
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
if (transport.type === 'websocket') {
|
|
251
|
+
if (!this.socket || this.socket.readyState !== plugins.WsWebSocket.OPEN) {
|
|
252
|
+
throw new Error('Codex server WebSocket is not open');
|
|
253
|
+
}
|
|
254
|
+
this.socket.send(payload, (error) => { if (error) this.handleError(error); });
|
|
255
|
+
} else {
|
|
256
|
+
if (transport.writable.destroyed || transport.writable.writableEnded) throw new Error('Codex server input is closed');
|
|
257
|
+
transport.writable.write(`${payload}\n`, (error) => { if (error) this.handleError(error); });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private receive(rawArg: string): void {
|
|
262
|
+
if (this.state === 'closed') return;
|
|
263
|
+
try {
|
|
264
|
+
const message: unknown = JSON.parse(rawArg);
|
|
265
|
+
if (!helpers.isRecord(message)) throw new Error('Codex server returned a malformed frame');
|
|
266
|
+
if (typeof message.method === 'string') {
|
|
267
|
+
const params = message.params ?? {};
|
|
268
|
+
if (!helpers.isRecord(params)) throw new Error(`Codex returned malformed ${message.method} params`);
|
|
269
|
+
if (message.id !== undefined) {
|
|
270
|
+
if ((typeof message.id !== 'string' && typeof message.id !== 'number')
|
|
271
|
+
|| this.serverRequests.has(message.id) || this.serverRequests.size >= 256) {
|
|
272
|
+
throw new Error('Codex server request identity or capacity is invalid');
|
|
273
|
+
}
|
|
274
|
+
const controller = new AbortController();
|
|
275
|
+
this.serverRequests.set(message.id, controller);
|
|
276
|
+
if (this.options.onServerRequest) this.options.onServerRequest({ id: message.id, method: message.method, params, signal: controller.signal });
|
|
277
|
+
else this.rejectRequest(message.id, -32601, `Client does not implement ${message.method}`);
|
|
278
|
+
} else {
|
|
279
|
+
if (message.method === 'serverRequest/resolved'
|
|
280
|
+
&& (typeof params.requestId === 'number' || typeof params.requestId === 'string')) {
|
|
281
|
+
this.finishServerRequest(params.requestId);
|
|
282
|
+
}
|
|
283
|
+
this.options.onNotification?.({ method: message.method, params });
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (typeof message.id !== 'number') return;
|
|
288
|
+
const pending = this.takePending(message.id);
|
|
289
|
+
if (!pending) return;
|
|
290
|
+
if (message.error !== undefined) {
|
|
291
|
+
const code = helpers.isRecord(message.error) && typeof message.error.code === 'number' ? message.error.code : undefined;
|
|
292
|
+
pending.reject(new CodexAppServerRequestError(
|
|
293
|
+
`Codex server rejected request: ${JSON.stringify(message.error).slice(0, 4000)}`, true, code));
|
|
294
|
+
} else pending.resolve(message.result);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.close(helpers.errorMessage(error));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private takePending(idArg: number): IPendingRequest | undefined {
|
|
301
|
+
const pending = this.pendingRequests.get(idArg);
|
|
302
|
+
if (!pending) return undefined;
|
|
303
|
+
this.pendingRequests.delete(idArg);
|
|
304
|
+
clearTimeout(pending.timer);
|
|
305
|
+
if (pending.abortHandler) pending.signal?.removeEventListener('abort', pending.abortHandler);
|
|
306
|
+
return pending;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private rejectPending(idArg: number, errorArg: Error): void {
|
|
310
|
+
this.takePending(idArg)?.reject(errorArg);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private finishServerRequest(idArg: TCodexAppServerRequestId): boolean {
|
|
314
|
+
const controller = this.serverRequests.get(idArg);
|
|
315
|
+
if (!controller) return false;
|
|
316
|
+
this.serverRequests.delete(idArg);
|
|
317
|
+
controller.abort();
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private assertOpen(): void {
|
|
322
|
+
if (this.state === 'closed') throw new Error('Codex server connection is closed');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private cancellationError(signalArg: AbortSignal, dispatchedArg: boolean): CodexAppServerRequestError {
|
|
326
|
+
return new CodexAppServerRequestError(
|
|
327
|
+
signalArg.reason instanceof Error ? signalArg.reason.message : 'operation cancelled',
|
|
328
|
+
dispatchedArg, undefined, { cause: signalArg.reason },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|