@omercnet/paseo-omp 0.4.0 → 1.0.0-next.148.1
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 +8 -8
- package/client/external-url.ts +2 -11
- package/client/omp-config-styles.ts +284 -0
- package/client/omp-config-surface.tsx +16 -286
- package/client/omp-routing-editor.tsx +1 -3
- package/client/provider-launch-settings.tsx +118 -0
- package/index.client.tsx +21 -2
- package/index.server.ts +10 -0
- package/package.json +14 -9
- package/paseo-plugin.json +2 -1
- package/scripts/prepare-dependencies.mjs +44 -11
- package/server/omp-models.ts +2 -6
- package/server/package-version.ts +1 -1
- package/server/protocol-violation-diagnostics.ts +1 -1
- package/server/provider/catalog.ts +2 -1
- package/server/provider/config-normalization.ts +19 -3
- package/server/provider/connection.ts +30 -0
- package/server/provider/host-tools.ts +2 -2
- package/server/provider/mcp-transport.ts +9 -2
- package/server/provider/omp-rpc-environment.ts +368 -0
- package/server/provider/omp-rpc-process.ts +119 -0
- package/server/provider/omp-rpc-protocol.ts +1949 -0
- package/server/provider/omp-rpc-transport.ts +1373 -0
- package/server/provider/omp-rpc-values.ts +31 -0
- package/server/provider/omp-rpc.ts +267 -3311
- package/server/provider/profile-providers.ts +2 -1
- package/server/provider/prompt-payload.ts +253 -0
- package/server/provider/registration.ts +10 -2
- package/server/provider/session-compaction.ts +187 -0
- package/server/provider/session-descriptors.ts +52 -18
- package/server/provider/session-events.ts +42 -0
- package/server/provider/session-permissions.ts +987 -0
- package/server/provider/session-terminal.ts +307 -0
- package/server/provider/session-usage.ts +234 -0
- package/server/provider/session.ts +145 -1853
- package/server/provider/settings.ts +8 -5
- package/server/provider/subsessions.ts +30 -9
- package/server/provider/timeline-projector.ts +140 -29
- package/shared/external-url.ts +0 -9
- package/shared/provider-launch-settings.ts +18 -0
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
buildOmpSpawnRequest,
|
|
6
|
+
type OmpSpawnRequest,
|
|
7
|
+
type OmpStartOptions,
|
|
8
|
+
} from "./omp-rpc-environment";
|
|
9
|
+
import {
|
|
10
|
+
isConfirmedNoProcessSpawnFailure,
|
|
11
|
+
PROCESS_STOP_TIMEOUT_MS,
|
|
12
|
+
type ProcessTreeCleanup,
|
|
13
|
+
stopWindowsTree,
|
|
14
|
+
terminatePosixProcessTree,
|
|
15
|
+
} from "./omp-rpc-process";
|
|
16
|
+
import {
|
|
17
|
+
createOptionalMetadataSanitizer,
|
|
18
|
+
invalidEventDiagnosticMetadata,
|
|
19
|
+
isBoundedToolApprovalId,
|
|
20
|
+
JsonObjectSchema,
|
|
21
|
+
jsonBoundViolation,
|
|
22
|
+
MAX_ARRAY_ITEMS,
|
|
23
|
+
MAX_CHUNK_BYTES,
|
|
24
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
25
|
+
MAX_MODEL_CATALOG_ITEMS,
|
|
26
|
+
MAX_REASSEMBLED_FRAME_BYTES,
|
|
27
|
+
MAX_RPC_ERROR_BYTES,
|
|
28
|
+
MAX_RPC_ERROR_CODE_BYTES,
|
|
29
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
30
|
+
MIN_HOST_TOOL_RESULT_FRAME_BYTES,
|
|
31
|
+
mapRuntimeFrameDetails,
|
|
32
|
+
OMP_MAX_CONTENT_PARTS,
|
|
33
|
+
OmpAgentEndEnvelopeSchema,
|
|
34
|
+
OmpChunkFrameSchema,
|
|
35
|
+
type OmpHostToolResult,
|
|
36
|
+
OmpHostToolResultSchema,
|
|
37
|
+
type OmpHostToolUpdate,
|
|
38
|
+
OmpHostToolUpdateSchema,
|
|
39
|
+
type OmpProtocolDiagnosticPhase,
|
|
40
|
+
type OmpProtocolViolationCategory,
|
|
41
|
+
type OmpProtocolViolationDiagnostic,
|
|
42
|
+
type OmpProtocolViolationReason,
|
|
43
|
+
OmpReadyFrameSchema,
|
|
44
|
+
OmpResponseFrameSchema,
|
|
45
|
+
type OmpRpcBoundDimension,
|
|
46
|
+
type OmpRpcDiagnosticCommand,
|
|
47
|
+
type OmpRpcEvent,
|
|
48
|
+
OmpRuntimeEventSchema,
|
|
49
|
+
omitOptionalDetails,
|
|
50
|
+
type PendingProtocolViolation,
|
|
51
|
+
type ProtocolViolationKey,
|
|
52
|
+
projectSessionStateResponseData,
|
|
53
|
+
protocolDiagnosticActualType,
|
|
54
|
+
rpcDiagnosticCommand,
|
|
55
|
+
runtimeFrameCollectionLimit,
|
|
56
|
+
sanitizeHistoryResponseData,
|
|
57
|
+
sanitizeLiveDisplayFrame,
|
|
58
|
+
} from "./omp-rpc-protocol";
|
|
59
|
+
import { boundedJsonBytes, OmpPublicError, utf8Bytes } from "./security";
|
|
60
|
+
|
|
61
|
+
type TimerHandle = number | NodeJS.Timeout;
|
|
62
|
+
type SpawnProcess = (request: OmpSpawnRequest) => ChildProcessWithoutNullStreams;
|
|
63
|
+
type TerminateProcessTree = (pid: number) => Promise<boolean | "uncertain">;
|
|
64
|
+
|
|
65
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
66
|
+
const CHUNK_STALE_MS = 30_000;
|
|
67
|
+
const MAX_PHYSICAL_FRAME_BYTES = 1024 * 1024;
|
|
68
|
+
const MAX_STREAM_TEXT_LENGTH = 4 * 1024 * 1024;
|
|
69
|
+
const MAX_ACTIVE_TOOLS = 64;
|
|
70
|
+
const MAX_PENDING_REQUESTS = 256;
|
|
71
|
+
const MAX_PENDING_ONE_WAY_WRITES = 256;
|
|
72
|
+
const MAX_PENDING_WRITE_BYTES = 8 * 1024 * 1024;
|
|
73
|
+
const MAX_LINE_PARTS = 4_096;
|
|
74
|
+
const PROMPT_SCHEDULING_FAILURE = "OMP prompt scheduling failed";
|
|
75
|
+
const PROTOCOL_VIOLATION_COALESCE_MS = 10_000;
|
|
76
|
+
|
|
77
|
+
export type OmpRpcRequestRejectionCode = "session_busy" | "stale_cursor" | "unsupported_command";
|
|
78
|
+
|
|
79
|
+
export class OmpRpcRequestRejectedError extends Error {
|
|
80
|
+
constructor(
|
|
81
|
+
readonly command?: OmpRpcDiagnosticCommand,
|
|
82
|
+
readonly code?: OmpRpcRequestRejectionCode,
|
|
83
|
+
) {
|
|
84
|
+
super("OMP RPC request failed");
|
|
85
|
+
this.name = "OmpRpcRequestRejectedError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class OmpRpcResponseLimitError extends Error {
|
|
90
|
+
constructor(
|
|
91
|
+
readonly command: OmpRpcDiagnosticCommand | undefined,
|
|
92
|
+
readonly bound: OmpRpcBoundDimension,
|
|
93
|
+
readonly actual: number,
|
|
94
|
+
readonly limit: number,
|
|
95
|
+
) {
|
|
96
|
+
super("OMP RPC response exceeded command limits");
|
|
97
|
+
this.name = "OmpRpcResponseLimitError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type PendingRequest = {
|
|
102
|
+
resolve(value: unknown): void;
|
|
103
|
+
reject(error: Error): void;
|
|
104
|
+
timer?: TimerHandle;
|
|
105
|
+
command: string;
|
|
106
|
+
beforeResolve?: (value: unknown) => void;
|
|
107
|
+
};
|
|
108
|
+
type StartedRequest = { id: string; promise: Promise<unknown> };
|
|
109
|
+
|
|
110
|
+
type ReadyFrame = z.infer<typeof OmpReadyFrameSchema>;
|
|
111
|
+
type ChunkFrame = z.infer<typeof OmpChunkFrameSchema>;
|
|
112
|
+
|
|
113
|
+
type ChunkState = {
|
|
114
|
+
id: string;
|
|
115
|
+
count: number;
|
|
116
|
+
byteLength: number;
|
|
117
|
+
parts: Buffer[];
|
|
118
|
+
receivedBytes: number;
|
|
119
|
+
timer: TimerHandle;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function waitWithTimeout<T>(
|
|
123
|
+
promise: Promise<T>,
|
|
124
|
+
timeoutMs: number,
|
|
125
|
+
message: string,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
const result = Promise.withResolvers<T>();
|
|
128
|
+
const timer = setTimeout(() => result.reject(new Error(message)), timeoutMs);
|
|
129
|
+
promise.then(
|
|
130
|
+
(value) => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
result.resolve(value);
|
|
133
|
+
},
|
|
134
|
+
(error) => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
result.reject(error);
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
return result.promise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class OmpRpcProcess {
|
|
143
|
+
readonly ready: Promise<ReadyFrame>;
|
|
144
|
+
readonly inheritedRedactionValues: readonly string[];
|
|
145
|
+
|
|
146
|
+
private readonly child: ChildProcessWithoutNullStreams;
|
|
147
|
+
private readonly listeners = new Set<(event: OmpRpcEvent) => void>();
|
|
148
|
+
private readonly pending = new Map<string, PendingRequest>();
|
|
149
|
+
private readonly queuedWrites = new Map<string, number>();
|
|
150
|
+
private readonly pendingOneWayWrites = new Map<
|
|
151
|
+
string,
|
|
152
|
+
{ reject(error: Error): void; timer: TimerHandle }
|
|
153
|
+
>();
|
|
154
|
+
private readonly acceptedPromptIds = new Set<string>();
|
|
155
|
+
private readonly exitPromise: Promise<void>;
|
|
156
|
+
private readonly resolveExit: () => void;
|
|
157
|
+
private readonly resolveReady: (frame: ReadyFrame) => void;
|
|
158
|
+
private readonly rejectReady: (error: Error) => void;
|
|
159
|
+
private readonly terminateProcessTree: (pid: number) => Promise<ProcessTreeCleanup>;
|
|
160
|
+
private readonly streamedBlocks = new Map<number, string>();
|
|
161
|
+
private readonly activeToolCallIds = new Set<string>();
|
|
162
|
+
private readonly announcedToolCallIds = new Set<string>();
|
|
163
|
+
private pendingWriteBytes = 0;
|
|
164
|
+
private commandTextLength = 0;
|
|
165
|
+
private lineParts: Buffer[] = [];
|
|
166
|
+
private lineBytes = 0;
|
|
167
|
+
private discardingLine = false;
|
|
168
|
+
private discardedLineBytes = 0;
|
|
169
|
+
private chunk: ChunkState | null = null;
|
|
170
|
+
private physicalFrameLimit = MAX_PHYSICAL_FRAME_BYTES;
|
|
171
|
+
private reassembledFrameLimit = MAX_REASSEMBLED_FRAME_BYTES;
|
|
172
|
+
private closed = false;
|
|
173
|
+
private exited = false;
|
|
174
|
+
private fatalError: Error | null = null;
|
|
175
|
+
private closePromise: Promise<void> | null = null;
|
|
176
|
+
private treeCleanupPromise: Promise<ProcessTreeCleanup> | null = null;
|
|
177
|
+
private spawnFailedWithoutProcess = false;
|
|
178
|
+
private readyReceived = false;
|
|
179
|
+
private outputSettled = false;
|
|
180
|
+
private turnActive = false;
|
|
181
|
+
private readonly pendingProtocolViolations = new Map<
|
|
182
|
+
ProtocolViolationKey,
|
|
183
|
+
PendingProtocolViolation
|
|
184
|
+
>();
|
|
185
|
+
private protocolViolationTimer: TimerHandle | null = null;
|
|
186
|
+
|
|
187
|
+
constructor(
|
|
188
|
+
options: OmpStartOptions,
|
|
189
|
+
spawnProcess?: SpawnProcess,
|
|
190
|
+
terminateProcessTree?: TerminateProcessTree,
|
|
191
|
+
private readonly requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
192
|
+
private readonly reportProtocolViolation: (
|
|
193
|
+
diagnostic: OmpProtocolViolationDiagnostic,
|
|
194
|
+
) => void | Promise<void> = (diagnostic) => console.error("OMP protocol violation", diagnostic),
|
|
195
|
+
) {
|
|
196
|
+
const ready = Promise.withResolvers<ReadyFrame>();
|
|
197
|
+
this.rejectReady = ready.reject;
|
|
198
|
+
this.ready = ready.promise;
|
|
199
|
+
this.resolveReady = ready.resolve;
|
|
200
|
+
const request = buildOmpSpawnRequest(options);
|
|
201
|
+
this.inheritedRedactionValues = request.inheritedRedactionValues;
|
|
202
|
+
this.terminateProcessTree = terminateProcessTree
|
|
203
|
+
? async (pid) => {
|
|
204
|
+
const outcome = await terminateProcessTree(pid);
|
|
205
|
+
return outcome === true ? "verified" : outcome === "uncertain" ? "uncertain" : "failed";
|
|
206
|
+
}
|
|
207
|
+
: async (pid) =>
|
|
208
|
+
process.platform === "win32"
|
|
209
|
+
? stopWindowsTree(pid)
|
|
210
|
+
: (await terminatePosixProcessTree(pid, PROCESS_STOP_TIMEOUT_MS))
|
|
211
|
+
? "verified"
|
|
212
|
+
: "failed";
|
|
213
|
+
try {
|
|
214
|
+
this.child = spawnProcess
|
|
215
|
+
? spawnProcess(request)
|
|
216
|
+
: spawn(request.command, request.args, {
|
|
217
|
+
cwd: request.cwd,
|
|
218
|
+
env: request.env,
|
|
219
|
+
detached: request.detached,
|
|
220
|
+
windowsHide: true,
|
|
221
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
222
|
+
});
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
const code = (cause as NodeJS.ErrnoException)?.code;
|
|
225
|
+
throw new Error(
|
|
226
|
+
code === "ENOENT"
|
|
227
|
+
? "OMP executable was not found"
|
|
228
|
+
: code === "EACCES" || code === "EPERM"
|
|
229
|
+
? "OMP executable is not runnable"
|
|
230
|
+
: "OMP process could not be launched",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
this.child.stdout.on("data", (chunk: Buffer | string) => this.receiveData(chunk));
|
|
234
|
+
this.child.stdout.once("end", () => this.handleStdoutEnd());
|
|
235
|
+
this.child.stderr.on("data", () => {
|
|
236
|
+
// Stderr is intentionally drained and discarded. It may contain credentials or paths.
|
|
237
|
+
});
|
|
238
|
+
this.child.stdin.on("error", () => {
|
|
239
|
+
this.fail(new Error("OMP RPC input channel failed"));
|
|
240
|
+
});
|
|
241
|
+
const exited = Promise.withResolvers<void>();
|
|
242
|
+
this.exitPromise = exited.promise;
|
|
243
|
+
this.resolveExit = exited.resolve;
|
|
244
|
+
this.child.once("exit", (code, signal) => this.handleProcessExit(code, signal));
|
|
245
|
+
this.child.once("close", () => this.settleOutput());
|
|
246
|
+
this.child.once("error", (cause) => {
|
|
247
|
+
const code = (cause as NodeJS.ErrnoException)?.code;
|
|
248
|
+
if (this.child.pid === undefined && isConfirmedNoProcessSpawnFailure(cause)) {
|
|
249
|
+
this.spawnFailedWithoutProcess = true;
|
|
250
|
+
}
|
|
251
|
+
this.fail(
|
|
252
|
+
new Error(
|
|
253
|
+
code === "ENOENT"
|
|
254
|
+
? "OMP executable was not found"
|
|
255
|
+
: code === "EACCES" || code === "EPERM"
|
|
256
|
+
? "OMP executable is not runnable"
|
|
257
|
+
: "OMP process could not be launched",
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private handleProcessExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
264
|
+
if (this.exited) return;
|
|
265
|
+
this.exited = true;
|
|
266
|
+
void this.startTreeCleanup();
|
|
267
|
+
const detail = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
|
|
268
|
+
const error = new Error(`OMP RPC process exited (${detail})`);
|
|
269
|
+
this.rejectReady(error);
|
|
270
|
+
this.failPending(error);
|
|
271
|
+
this.resolveExit();
|
|
272
|
+
if (!this.closed && !this.fatalError) this.fail(error);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private handleStdoutEnd(): void {
|
|
276
|
+
this.settleOutput();
|
|
277
|
+
if (!this.exited && !this.closed) this.fail(new Error("OMP RPC output channel closed"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private settleOutput(): void {
|
|
281
|
+
if (this.outputSettled) return;
|
|
282
|
+
this.outputSettled = true;
|
|
283
|
+
if (this.lineBytes > 0 || this.discardingLine) {
|
|
284
|
+
this.recordProtocolViolation("incomplete-frame", {
|
|
285
|
+
reason: "output-ended-mid-frame",
|
|
286
|
+
field: "frame",
|
|
287
|
+
expected: "complete-json-line",
|
|
288
|
+
actualType: "partial-frame",
|
|
289
|
+
maxByteSize: this.discardingLine ? this.discardedLineBytes : this.lineBytes,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
this.lineParts = [];
|
|
293
|
+
this.lineBytes = 0;
|
|
294
|
+
this.discardingLine = false;
|
|
295
|
+
this.discardedLineBytes = 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
get outboundFrameLimit(): number {
|
|
299
|
+
return this.physicalFrameLimit;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
onEvent(listener: (event: OmpRpcEvent) => void): () => void {
|
|
303
|
+
this.listeners.add(listener);
|
|
304
|
+
return () => this.listeners.delete(listener);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
applyReadyLimits(frame: ReadyFrame): void {
|
|
308
|
+
if (frame.maxFrameBytes !== undefined) this.physicalFrameLimit = frame.maxFrameBytes;
|
|
309
|
+
if (frame.maxReassembledFrameBytes !== undefined) {
|
|
310
|
+
this.reassembledFrameLimit = frame.maxReassembledFrameBytes;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
startRequest(
|
|
315
|
+
command: Record<string, unknown>,
|
|
316
|
+
timeoutMs: number | null = this.requestTimeoutMs,
|
|
317
|
+
beforeResolve?: (value: unknown) => void,
|
|
318
|
+
): StartedRequest {
|
|
319
|
+
const id = randomUUID();
|
|
320
|
+
if (this.fatalError) return { id, promise: Promise.reject(this.fatalError) };
|
|
321
|
+
if (this.closed || this.exited || !this.child.stdin.writable) {
|
|
322
|
+
return { id, promise: Promise.reject(new Error("OMP RPC process is closed")) };
|
|
323
|
+
}
|
|
324
|
+
let payload: Buffer;
|
|
325
|
+
try {
|
|
326
|
+
payload = Buffer.from(`${JSON.stringify({ ...command, id })}\n`);
|
|
327
|
+
} catch {
|
|
328
|
+
return { id, promise: Promise.reject(new Error("OMP RPC request could not be encoded")) };
|
|
329
|
+
}
|
|
330
|
+
if (payload.byteLength > this.physicalFrameLimit) {
|
|
331
|
+
return {
|
|
332
|
+
id,
|
|
333
|
+
promise: Promise.reject(new Error("OMP RPC request exceeds the negotiated frame limit")),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
this.pending.size >= MAX_PENDING_REQUESTS ||
|
|
338
|
+
this.pendingWriteBytes + payload.byteLength > MAX_PENDING_WRITE_BYTES
|
|
339
|
+
) {
|
|
340
|
+
return { id, promise: Promise.reject(new Error("OMP RPC has too many pending requests")) };
|
|
341
|
+
}
|
|
342
|
+
const result = Promise.withResolvers<unknown>();
|
|
343
|
+
const timer =
|
|
344
|
+
timeoutMs === null
|
|
345
|
+
? undefined
|
|
346
|
+
: setTimeout(() => {
|
|
347
|
+
this.pending.delete(id);
|
|
348
|
+
result.reject(new Error("OMP RPC request timed out"));
|
|
349
|
+
}, timeoutMs);
|
|
350
|
+
this.pending.set(id, {
|
|
351
|
+
resolve: result.resolve,
|
|
352
|
+
reject: result.reject,
|
|
353
|
+
timer,
|
|
354
|
+
command: typeof command.type === "string" ? command.type : "unknown",
|
|
355
|
+
...(beforeResolve ? { beforeResolve } : {}),
|
|
356
|
+
});
|
|
357
|
+
this.queuedWrites.set(id, payload.byteLength);
|
|
358
|
+
this.pendingWriteBytes += payload.byteLength;
|
|
359
|
+
try {
|
|
360
|
+
this.child.stdin.write(payload, (cause) => {
|
|
361
|
+
this.releaseQueuedWrite(id);
|
|
362
|
+
if (cause) this.fail(new Error("OMP RPC input channel failed"));
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
this.releaseQueuedWrite(id);
|
|
366
|
+
this.fail(new Error("OMP RPC input channel failed"));
|
|
367
|
+
}
|
|
368
|
+
return { id, promise: result.promise };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
request(
|
|
372
|
+
command: Record<string, unknown>,
|
|
373
|
+
timeoutMs: number | null = this.requestTimeoutMs,
|
|
374
|
+
): Promise<unknown> {
|
|
375
|
+
return this.startRequest(command, timeoutMs).promise;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
send(frame: OmpHostToolResult | OmpHostToolUpdate): void {
|
|
379
|
+
if (this.fatalError) throw this.fatalError;
|
|
380
|
+
if (this.closed || this.exited || !this.child.stdin.writable) {
|
|
381
|
+
throw new Error("OMP RPC process is closed");
|
|
382
|
+
}
|
|
383
|
+
const parsed =
|
|
384
|
+
frame.type === "host_tool_result"
|
|
385
|
+
? OmpHostToolResultSchema.parse(frame)
|
|
386
|
+
: OmpHostToolUpdateSchema.parse(frame);
|
|
387
|
+
const payload = Buffer.from(`${JSON.stringify(parsed)}\n`);
|
|
388
|
+
if (payload.byteLength > this.physicalFrameLimit) {
|
|
389
|
+
throw new Error("OMP host tool frame exceeds the negotiated frame limit");
|
|
390
|
+
}
|
|
391
|
+
if (this.pendingWriteBytes + payload.byteLength > MAX_PENDING_WRITE_BYTES) {
|
|
392
|
+
throw new Error("OMP RPC has too many pending writes");
|
|
393
|
+
}
|
|
394
|
+
const writeId = randomUUID();
|
|
395
|
+
this.queuedWrites.set(writeId, payload.byteLength);
|
|
396
|
+
this.pendingWriteBytes += payload.byteLength;
|
|
397
|
+
try {
|
|
398
|
+
this.child.stdin.write(payload, (cause) => {
|
|
399
|
+
this.releaseQueuedWrite(writeId);
|
|
400
|
+
if (cause) this.fail(new Error("OMP RPC input channel failed"));
|
|
401
|
+
});
|
|
402
|
+
} catch {
|
|
403
|
+
this.releaseQueuedWrite(writeId);
|
|
404
|
+
this.fail(new Error("OMP RPC input channel failed"));
|
|
405
|
+
throw new Error("OMP RPC input channel failed");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
sendFrame(frame: Record<string, unknown>, timeoutMs = this.requestTimeoutMs): Promise<void> {
|
|
410
|
+
if (this.fatalError) return Promise.reject(this.fatalError);
|
|
411
|
+
if (this.closed || this.exited || !this.child.stdin.writable) {
|
|
412
|
+
return Promise.reject(new Error("OMP RPC process is closed"));
|
|
413
|
+
}
|
|
414
|
+
let payload: Buffer;
|
|
415
|
+
try {
|
|
416
|
+
payload = Buffer.from(`${JSON.stringify(frame)}\n`);
|
|
417
|
+
} catch {
|
|
418
|
+
return Promise.reject(new Error("OMP RPC frame could not be encoded"));
|
|
419
|
+
}
|
|
420
|
+
if (payload.byteLength > this.physicalFrameLimit) {
|
|
421
|
+
return Promise.reject(new Error("OMP RPC frame exceeds the negotiated frame limit"));
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
this.pendingOneWayWrites.size >= MAX_PENDING_ONE_WAY_WRITES ||
|
|
425
|
+
this.pendingWriteBytes + payload.byteLength > MAX_PENDING_WRITE_BYTES
|
|
426
|
+
) {
|
|
427
|
+
return Promise.reject(new Error("OMP RPC has too many pending writes"));
|
|
428
|
+
}
|
|
429
|
+
const token = randomUUID();
|
|
430
|
+
const written = Promise.withResolvers<void>();
|
|
431
|
+
const timer = setTimeout(() => {
|
|
432
|
+
if (!this.pendingOneWayWrites.delete(token)) return;
|
|
433
|
+
this.releaseQueuedWrite(token);
|
|
434
|
+
const error = new Error("OMP RPC write timed out");
|
|
435
|
+
written.reject(error);
|
|
436
|
+
this.fail(error);
|
|
437
|
+
}, timeoutMs);
|
|
438
|
+
this.pendingOneWayWrites.set(token, { reject: written.reject, timer });
|
|
439
|
+
this.queuedWrites.set(token, payload.byteLength);
|
|
440
|
+
this.pendingWriteBytes += payload.byteLength;
|
|
441
|
+
try {
|
|
442
|
+
this.child.stdin.write(payload, (cause) => {
|
|
443
|
+
const pending = this.pendingOneWayWrites.get(token);
|
|
444
|
+
if (!pending) return;
|
|
445
|
+
clearTimeout(pending.timer);
|
|
446
|
+
this.pendingOneWayWrites.delete(token);
|
|
447
|
+
this.releaseQueuedWrite(token);
|
|
448
|
+
if (cause) {
|
|
449
|
+
const error = new Error("OMP RPC input channel failed");
|
|
450
|
+
written.reject(error);
|
|
451
|
+
this.fail(error);
|
|
452
|
+
} else {
|
|
453
|
+
written.resolve();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
} catch {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
this.pendingOneWayWrites.delete(token);
|
|
459
|
+
this.releaseQueuedWrite(token);
|
|
460
|
+
const error = new Error("OMP RPC input channel failed");
|
|
461
|
+
written.reject(error);
|
|
462
|
+
this.fail(error);
|
|
463
|
+
}
|
|
464
|
+
return written.promise;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
close(): Promise<void> {
|
|
468
|
+
this.closePromise ??= this.closeProcess();
|
|
469
|
+
return this.closePromise;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async closeProcess(): Promise<void> {
|
|
473
|
+
this.closed = true;
|
|
474
|
+
this.flushProtocolViolations();
|
|
475
|
+
this.clearChunk();
|
|
476
|
+
this.failPending(new Error("OMP RPC process was closed"));
|
|
477
|
+
const cleanupPromise = this.startTreeCleanup();
|
|
478
|
+
if (process.platform !== "win32" && !this.exited) {
|
|
479
|
+
try {
|
|
480
|
+
this.child.stdin.end();
|
|
481
|
+
} catch {
|
|
482
|
+
// Continue waiting for process-tree cleanup when the input channel is already closed.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const cleanup = await cleanupPromise;
|
|
486
|
+
if (cleanup !== "verified") throw new Error("OMP RPC process tree cleanup failed");
|
|
487
|
+
if (process.platform === "win32" && !this.exited) {
|
|
488
|
+
try {
|
|
489
|
+
this.child.stdin.end();
|
|
490
|
+
} catch {
|
|
491
|
+
// Process-tree cleanup remains authoritative when the input channel is already closed.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (
|
|
495
|
+
!this.spawnFailedWithoutProcess &&
|
|
496
|
+
!this.exited &&
|
|
497
|
+
!(await this.waitForExit(PROCESS_STOP_TIMEOUT_MS))
|
|
498
|
+
) {
|
|
499
|
+
throw new Error("OMP RPC process did not close after tree cleanup");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private startTreeCleanup(): Promise<ProcessTreeCleanup> {
|
|
504
|
+
if (this.treeCleanupPromise) return this.treeCleanupPromise;
|
|
505
|
+
const pid = this.child.pid;
|
|
506
|
+
this.treeCleanupPromise = (
|
|
507
|
+
pid === undefined
|
|
508
|
+
? Promise.resolve<ProcessTreeCleanup>(
|
|
509
|
+
this.spawnFailedWithoutProcess ? "verified" : "uncertain",
|
|
510
|
+
)
|
|
511
|
+
: this.terminateProcessTree(pid)
|
|
512
|
+
).catch(() => "failed");
|
|
513
|
+
return this.treeCleanupPromise;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async waitForExit(timeoutMs: number): Promise<boolean> {
|
|
517
|
+
const timeout = Promise.withResolvers<false>();
|
|
518
|
+
const timer = setTimeout(() => timeout.resolve(false), timeoutMs);
|
|
519
|
+
try {
|
|
520
|
+
return await Promise.race([this.exitPromise.then(() => true), timeout.promise]);
|
|
521
|
+
} finally {
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private receiveData(chunk: Buffer | string): void {
|
|
527
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
528
|
+
let start = 0;
|
|
529
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
530
|
+
if (bytes[index] !== 10) continue;
|
|
531
|
+
const part = bytes.subarray(start, index);
|
|
532
|
+
if (this.discardingLine) {
|
|
533
|
+
this.discardedLineBytes += part.byteLength;
|
|
534
|
+
if (this.discardedLineBytes > MAX_SEMANTIC_FRAME_BYTES) {
|
|
535
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
this.resetDiscardedLine();
|
|
539
|
+
} else {
|
|
540
|
+
this.appendLinePart(part);
|
|
541
|
+
if (this.fatalError) return;
|
|
542
|
+
if (this.discardingLine) {
|
|
543
|
+
if (this.discardedLineBytes > MAX_SEMANTIC_FRAME_BYTES) {
|
|
544
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.resetDiscardedLine();
|
|
548
|
+
} else this.completeLine();
|
|
549
|
+
}
|
|
550
|
+
start = index + 1;
|
|
551
|
+
}
|
|
552
|
+
if (start >= bytes.length) return;
|
|
553
|
+
const trailing = bytes.subarray(start);
|
|
554
|
+
if (this.discardingLine) {
|
|
555
|
+
this.discardedLineBytes += trailing.byteLength;
|
|
556
|
+
if (this.discardedLineBytes > MAX_SEMANTIC_FRAME_BYTES) {
|
|
557
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
this.appendLinePart(trailing);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private resetDiscardedLine(): void {
|
|
565
|
+
this.discardingLine = false;
|
|
566
|
+
this.discardedLineBytes = 0;
|
|
567
|
+
this.lineParts = [];
|
|
568
|
+
this.lineBytes = 0;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private appendLinePart(part: Buffer): void {
|
|
572
|
+
if (part.byteLength === 0) return;
|
|
573
|
+
const nextBytes = this.lineBytes + part.byteLength;
|
|
574
|
+
if (nextBytes > MAX_SEMANTIC_FRAME_BYTES) {
|
|
575
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (this.lineParts.length >= MAX_LINE_PARTS || nextBytes > this.physicalFrameLimit) {
|
|
579
|
+
this.discardedLineBytes = nextBytes;
|
|
580
|
+
this.lineParts = [];
|
|
581
|
+
this.lineBytes = 0;
|
|
582
|
+
this.discardingLine = true;
|
|
583
|
+
this.recordProtocolViolation("frame-limit", {
|
|
584
|
+
reason: "physical-frame-limit",
|
|
585
|
+
field: "frame.byteLength",
|
|
586
|
+
expected: "within-byte-limit",
|
|
587
|
+
actualType: "oversized",
|
|
588
|
+
maxByteSize: nextBytes,
|
|
589
|
+
limitBytes: this.physicalFrameLimit,
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.lineParts.push(part);
|
|
594
|
+
this.lineBytes = nextBytes;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private completeLine(): void {
|
|
598
|
+
if (this.lineBytes === 0) return;
|
|
599
|
+
const line = Buffer.concat(this.lineParts, this.lineBytes);
|
|
600
|
+
this.lineParts = [];
|
|
601
|
+
this.lineBytes = 0;
|
|
602
|
+
const payload = line.at(-1) === 13 ? line.subarray(0, -1) : line;
|
|
603
|
+
let decoded: unknown;
|
|
604
|
+
try {
|
|
605
|
+
decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload));
|
|
606
|
+
} catch {
|
|
607
|
+
this.recordProtocolViolation("invalid-json", {
|
|
608
|
+
reason: "json-decode",
|
|
609
|
+
field: "frame",
|
|
610
|
+
expected: "valid-json",
|
|
611
|
+
actualType: "invalid-json",
|
|
612
|
+
maxByteSize: payload.byteLength,
|
|
613
|
+
});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.receiveDecodedFrame(decoded, payload.byteLength);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private receiveChunk(frame: ChunkFrame): void {
|
|
620
|
+
if (
|
|
621
|
+
frame.byteLength > MAX_SEMANTIC_FRAME_BYTES &&
|
|
622
|
+
![...this.pending.values()].some(
|
|
623
|
+
(pending) =>
|
|
624
|
+
pending.command === "get_messages" ||
|
|
625
|
+
pending.command === "get_messages_page" ||
|
|
626
|
+
pending.command === "get_subagent_messages" ||
|
|
627
|
+
pending.command === "get_state",
|
|
628
|
+
)
|
|
629
|
+
) {
|
|
630
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (
|
|
634
|
+
frame.index >= frame.count ||
|
|
635
|
+
frame.byteLength > this.reassembledFrameLimit ||
|
|
636
|
+
frame.data.length % 4 !== 0 ||
|
|
637
|
+
!/^[A-Za-z0-9+/]*={0,2}$/u.test(frame.data)
|
|
638
|
+
) {
|
|
639
|
+
this.rejectChunk("chunk-metadata", {
|
|
640
|
+
field: "chunk",
|
|
641
|
+
expected: "valid-chunk-frame",
|
|
642
|
+
actualType: "object",
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const decoded = Buffer.from(frame.data, "base64");
|
|
647
|
+
if (decoded.byteLength > MAX_CHUNK_BYTES) {
|
|
648
|
+
this.rejectChunk("chunk-size", {
|
|
649
|
+
field: "chunk.data",
|
|
650
|
+
expected: "within-byte-limit",
|
|
651
|
+
actualType: "oversized",
|
|
652
|
+
limitBytes: MAX_CHUNK_BYTES,
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (!this.chunk) {
|
|
657
|
+
if (frame.index !== 0) {
|
|
658
|
+
this.rejectChunk("chunk-start-index", {
|
|
659
|
+
field: "chunk.index",
|
|
660
|
+
expected: "first-chunk-index-zero",
|
|
661
|
+
actualType: "out-of-order",
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
this.chunk = {
|
|
666
|
+
id: frame.chunkId,
|
|
667
|
+
count: frame.count,
|
|
668
|
+
byteLength: frame.byteLength,
|
|
669
|
+
parts: [],
|
|
670
|
+
receivedBytes: 0,
|
|
671
|
+
timer: setTimeout(
|
|
672
|
+
() =>
|
|
673
|
+
this.rejectChunk("chunk-timeout", {
|
|
674
|
+
field: "chunk.sequence",
|
|
675
|
+
expected: "chunk-before-deadline",
|
|
676
|
+
actualType: "timeout",
|
|
677
|
+
}),
|
|
678
|
+
CHUNK_STALE_MS,
|
|
679
|
+
),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const chunk = this.chunk;
|
|
683
|
+
if (
|
|
684
|
+
chunk.id !== frame.chunkId ||
|
|
685
|
+
chunk.count !== frame.count ||
|
|
686
|
+
chunk.byteLength !== frame.byteLength ||
|
|
687
|
+
chunk.parts.length !== frame.index ||
|
|
688
|
+
chunk.receivedBytes + decoded.byteLength > chunk.byteLength ||
|
|
689
|
+
chunk.receivedBytes + decoded.byteLength > this.reassembledFrameLimit
|
|
690
|
+
) {
|
|
691
|
+
this.rejectChunk("chunk-sequence", {
|
|
692
|
+
field: "chunk.sequence",
|
|
693
|
+
expected: "contiguous-chunk-sequence",
|
|
694
|
+
actualType: "out-of-order",
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
chunk.parts.push(decoded);
|
|
699
|
+
chunk.receivedBytes += decoded.byteLength;
|
|
700
|
+
if (chunk.parts.length !== chunk.count) return;
|
|
701
|
+
if (chunk.receivedBytes !== chunk.byteLength) {
|
|
702
|
+
this.rejectChunk("chunk-byte-count", {
|
|
703
|
+
field: "chunk.byteLength",
|
|
704
|
+
expected: "declared-chunk-byte-count",
|
|
705
|
+
actualType: "mismatched",
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const reassembled = Buffer.concat(chunk.parts, chunk.receivedBytes);
|
|
710
|
+
this.clearChunk();
|
|
711
|
+
let decodedFrame: unknown;
|
|
712
|
+
try {
|
|
713
|
+
decodedFrame = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(reassembled));
|
|
714
|
+
} catch {
|
|
715
|
+
this.recordProtocolViolation("invalid-json", {
|
|
716
|
+
reason: "chunk-json-decode",
|
|
717
|
+
field: "chunk.data",
|
|
718
|
+
expected: "valid-json",
|
|
719
|
+
actualType: "invalid-json",
|
|
720
|
+
maxByteSize: reassembled.byteLength,
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
this.receiveDecodedFrame(decodedFrame, reassembled.byteLength);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private receiveDecodedFrame(value: unknown, rawByteLength: number): void {
|
|
728
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
729
|
+
const frame = value as Record<string, unknown>;
|
|
730
|
+
const pending = typeof frame.id === "string" ? this.pending.get(frame.id) : undefined;
|
|
731
|
+
if (
|
|
732
|
+
frame.type === "response" &&
|
|
733
|
+
(pending?.command === "get_messages" ||
|
|
734
|
+
pending?.command === "get_messages_page" ||
|
|
735
|
+
pending?.command === "get_subagent_messages" ||
|
|
736
|
+
pending?.command === "get_state")
|
|
737
|
+
) {
|
|
738
|
+
this.receiveResponse(frame);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (this.receiveUnsupportedCommandResponse(value)) return;
|
|
743
|
+
if (rawByteLength > MAX_SEMANTIC_FRAME_BYTES) {
|
|
744
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (this.receiveKnownResponse(value)) return;
|
|
748
|
+
const sanitized = sanitizeLiveDisplayFrame(value);
|
|
749
|
+
const frame = JsonObjectSchema.safeParse(sanitized);
|
|
750
|
+
if (!frame.success) {
|
|
751
|
+
this.recordProtocolViolation("invalid-envelope", {
|
|
752
|
+
reason: "object-envelope",
|
|
753
|
+
field: "frame",
|
|
754
|
+
expected: "object-envelope",
|
|
755
|
+
actualType: protocolDiagnosticActualType(sanitized),
|
|
756
|
+
maxByteSize: rawByteLength,
|
|
757
|
+
});
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
this.receiveFrame(frame.data, rawByteLength);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private receiveKnownResponse(value: unknown): boolean {
|
|
764
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
765
|
+
const frame = value as Record<string, unknown>;
|
|
766
|
+
if (
|
|
767
|
+
frame.type !== "response" ||
|
|
768
|
+
typeof frame.id !== "string" ||
|
|
769
|
+
(!this.pending.has(frame.id) && !this.acceptedPromptIds.has(frame.id))
|
|
770
|
+
) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
this.receiveResponse(frame);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private receiveUnsupportedCommandResponse(value: unknown): boolean {
|
|
778
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
779
|
+
const frame = value as Record<string, unknown>;
|
|
780
|
+
if (
|
|
781
|
+
frame.type !== "response" ||
|
|
782
|
+
frame.id !== undefined ||
|
|
783
|
+
frame.success !== false ||
|
|
784
|
+
typeof frame.command !== "string" ||
|
|
785
|
+
frame.error !== `Unknown command: ${frame.command}`
|
|
786
|
+
) {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
const matches = [...this.pending.entries()].filter(
|
|
790
|
+
([, pending]) => pending.command === frame.command,
|
|
791
|
+
);
|
|
792
|
+
if (matches.length === 0) return false;
|
|
793
|
+
const [id, pending] = matches[0] ?? [];
|
|
794
|
+
if (!id || !pending) return false;
|
|
795
|
+
this.takePending(id)?.reject(
|
|
796
|
+
new OmpRpcRequestRejectedError(rpcDiagnosticCommand(pending.command), "unsupported_command"),
|
|
797
|
+
);
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private receiveResponse(frame: Record<string, unknown>): void {
|
|
802
|
+
const rawId = typeof frame.id === "string" ? frame.id : undefined;
|
|
803
|
+
const knownPending = rawId ? this.pending.get(rawId) : undefined;
|
|
804
|
+
const response = OmpResponseFrameSchema.safeParse(frame);
|
|
805
|
+
if (!response.success) {
|
|
806
|
+
const handledAcceptedFailure =
|
|
807
|
+
rawId !== undefined && knownPending === undefined
|
|
808
|
+
? this.emitAcceptedPromptFailure(rawId, frame)
|
|
809
|
+
: false;
|
|
810
|
+
if (!handledAcceptedFailure) {
|
|
811
|
+
this.recordProtocolViolation("invalid-response", {
|
|
812
|
+
reason: "response-schema",
|
|
813
|
+
frameType: "response",
|
|
814
|
+
field: "response",
|
|
815
|
+
expected: "valid-response-frame",
|
|
816
|
+
actualType: "object",
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
if (rawId && knownPending) {
|
|
820
|
+
this.takePending(rawId)?.reject(new Error("OMP RPC response is invalid"));
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const pending = this.pending.get(response.data.id);
|
|
825
|
+
if (!pending) {
|
|
826
|
+
if (!response.data.success) {
|
|
827
|
+
this.emitAcceptedPromptFailure(response.data.id, response.data);
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const isBranchHistory = pending.command === "get_branch_messages";
|
|
832
|
+
const isHistory =
|
|
833
|
+
pending.command === "get_messages" ||
|
|
834
|
+
pending.command === "get_messages_page" ||
|
|
835
|
+
pending.command === "get_subagent_messages";
|
|
836
|
+
const rawResponseData =
|
|
837
|
+
pending.command === "get_state"
|
|
838
|
+
? projectSessionStateResponseData(response.data.data)
|
|
839
|
+
: response.data.data;
|
|
840
|
+
const responseData = isHistory ? sanitizeHistoryResponseData(rawResponseData) : rawResponseData;
|
|
841
|
+
const boundedFrame =
|
|
842
|
+
responseData === response.data.data ? frame : { ...frame, data: responseData };
|
|
843
|
+
const responseItemLimit = isBranchHistory
|
|
844
|
+
? 1_024
|
|
845
|
+
: isHistory
|
|
846
|
+
? 100_000
|
|
847
|
+
: pending.command === "get_available_models"
|
|
848
|
+
? MAX_MODEL_CATALOG_ITEMS
|
|
849
|
+
: MAX_ARRAY_ITEMS;
|
|
850
|
+
const responseByteLimit =
|
|
851
|
+
isBranchHistory || isHistory
|
|
852
|
+
? Math.min(MAX_REASSEMBLED_FRAME_BYTES, this.reassembledFrameLimit)
|
|
853
|
+
: 2 * 1024 * 1024;
|
|
854
|
+
// Model catalogs are truncated before publication, while the transport still enforces
|
|
855
|
+
// aggregate byte and node budgets over the complete response.
|
|
856
|
+
const responseNodeLimit = isBranchHistory
|
|
857
|
+
? 4_096
|
|
858
|
+
: isHistory
|
|
859
|
+
? 400_000
|
|
860
|
+
: pending.command === "get_available_models"
|
|
861
|
+
? 16_384
|
|
862
|
+
: 2_048;
|
|
863
|
+
const violation = jsonBoundViolation(
|
|
864
|
+
boundedFrame,
|
|
865
|
+
responseByteLimit,
|
|
866
|
+
responseItemLimit,
|
|
867
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
868
|
+
responseNodeLimit,
|
|
869
|
+
);
|
|
870
|
+
if (violation) {
|
|
871
|
+
this.takePending(response.data.id)?.reject(
|
|
872
|
+
new OmpRpcResponseLimitError(
|
|
873
|
+
rpcDiagnosticCommand(pending.command),
|
|
874
|
+
violation.dimension,
|
|
875
|
+
violation.actual,
|
|
876
|
+
violation.limit,
|
|
877
|
+
),
|
|
878
|
+
);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const settled = this.takePending(response.data.id);
|
|
882
|
+
if (!settled) return;
|
|
883
|
+
if (response.data.success) {
|
|
884
|
+
try {
|
|
885
|
+
settled.beforeResolve?.(responseData);
|
|
886
|
+
if (settled.command === "prompt") {
|
|
887
|
+
if (this.acceptedPromptIds.size >= MAX_PENDING_REQUESTS) {
|
|
888
|
+
const oldest = this.acceptedPromptIds.values().next().value;
|
|
889
|
+
if (oldest !== undefined) this.acceptedPromptIds.delete(oldest);
|
|
890
|
+
}
|
|
891
|
+
this.acceptedPromptIds.add(response.data.id);
|
|
892
|
+
}
|
|
893
|
+
settled.resolve(responseData);
|
|
894
|
+
} catch {
|
|
895
|
+
settled.reject(new Error("OMP RPC response is invalid"));
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
const responseCode = response.data.code;
|
|
899
|
+
const code: OmpRpcRequestRejectionCode | undefined =
|
|
900
|
+
responseCode === "session_busy" ||
|
|
901
|
+
responseCode === "stale_cursor" ||
|
|
902
|
+
responseCode === "unsupported_command"
|
|
903
|
+
? responseCode
|
|
904
|
+
: response.data.error === `Unknown command: ${pending.command}`
|
|
905
|
+
? "unsupported_command"
|
|
906
|
+
: undefined;
|
|
907
|
+
settled.reject(new OmpRpcRequestRejectedError(rpcDiagnosticCommand(pending.command), code));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private emitAcceptedPromptFailure(id: string, frame: Record<string, unknown>): boolean {
|
|
912
|
+
if (frame.success !== false || !this.acceptedPromptIds.delete(id)) return false;
|
|
913
|
+
const error = typeof frame.error === "string" ? frame.error : undefined;
|
|
914
|
+
const nativeError =
|
|
915
|
+
error && utf8Bytes(error) <= MAX_RPC_ERROR_BYTES ? error : PROMPT_SCHEDULING_FAILURE;
|
|
916
|
+
const code = typeof frame.code === "string" ? frame.code : undefined;
|
|
917
|
+
const nativeCode = code && utf8Bytes(code) <= MAX_RPC_ERROR_CODE_BYTES ? code : undefined;
|
|
918
|
+
this.emit({
|
|
919
|
+
type: "prompt_error",
|
|
920
|
+
id,
|
|
921
|
+
error: nativeError,
|
|
922
|
+
...(nativeCode ? { code: nativeCode } : {}),
|
|
923
|
+
});
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private takePending(id: string): PendingRequest | undefined {
|
|
928
|
+
const pending = this.pending.get(id);
|
|
929
|
+
if (!pending) return undefined;
|
|
930
|
+
clearTimeout(pending.timer);
|
|
931
|
+
this.pending.delete(id);
|
|
932
|
+
return pending;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private releaseQueuedWrite(id: string): void {
|
|
936
|
+
const bytes = this.queuedWrites.get(id);
|
|
937
|
+
if (bytes === undefined) return;
|
|
938
|
+
this.queuedWrites.delete(id);
|
|
939
|
+
this.pendingWriteBytes -= bytes;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private receiveDegradedAgentEnd(value: unknown, onlyUnsafePayload: boolean): boolean {
|
|
943
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
944
|
+
const frame = value as Record<string, unknown>;
|
|
945
|
+
if (frame.type !== "agent_end") return false;
|
|
946
|
+
const envelope = OmpAgentEndEnvelopeSchema.safeParse(frame);
|
|
947
|
+
if (!envelope.success) {
|
|
948
|
+
this.fail(new Error("OMP emitted invalid terminal metadata"));
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
const structuralFrame = mapRuntimeFrameDetails(frame, omitOptionalDetails);
|
|
952
|
+
const messagesAreSafe =
|
|
953
|
+
frame.messages === undefined ||
|
|
954
|
+
(Array.isArray(frame.messages) &&
|
|
955
|
+
frame.messages.length <= MAX_ARRAY_ITEMS &&
|
|
956
|
+
boundedJsonBytes(
|
|
957
|
+
structuralFrame.messages as unknown[],
|
|
958
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
959
|
+
OMP_MAX_CONTENT_PARTS,
|
|
960
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
961
|
+
4_096,
|
|
962
|
+
) !== Number.POSITIVE_INFINITY);
|
|
963
|
+
const payloadIsSafe =
|
|
964
|
+
messagesAreSafe &&
|
|
965
|
+
boundedJsonBytes(
|
|
966
|
+
structuralFrame,
|
|
967
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
968
|
+
OMP_MAX_CONTENT_PARTS,
|
|
969
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
970
|
+
4_096,
|
|
971
|
+
) !== Number.POSITIVE_INFINITY;
|
|
972
|
+
if (onlyUnsafePayload && payloadIsSafe) return false;
|
|
973
|
+
if (envelope.data.isTerminal === false) {
|
|
974
|
+
this.fail(new Error("OMP emitted an invalid nonterminal agent_end payload"));
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
const observedCount = Array.isArray(frame.messages)
|
|
978
|
+
? frame.messages.length
|
|
979
|
+
: Object.hasOwn(frame, "messages")
|
|
980
|
+
? 1
|
|
981
|
+
: undefined;
|
|
982
|
+
const messageCount = Math.max(envelope.data.messageCount ?? 0, observedCount ?? 0, 1);
|
|
983
|
+
this.emit({
|
|
984
|
+
...envelope.data,
|
|
985
|
+
messageCount,
|
|
986
|
+
});
|
|
987
|
+
this.streamedBlocks.clear();
|
|
988
|
+
this.commandTextLength = 0;
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private receiveFrame(frame: Record<string, unknown>, rawByteLength: number): void {
|
|
993
|
+
const type = typeof frame.type === "string" && frame.type.length <= 64 ? frame.type : null;
|
|
994
|
+
if (!type) {
|
|
995
|
+
this.recordProtocolViolation("invalid-envelope", {
|
|
996
|
+
reason: "missing-frame-type",
|
|
997
|
+
field: "frame.type",
|
|
998
|
+
expected: "bounded-frame-type",
|
|
999
|
+
actualType: protocolDiagnosticActualType(frame.type),
|
|
1000
|
+
maxByteSize: rawByteLength,
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
|
|
1005
|
+
if (this.receiveDegradedAgentEnd(safeFrame, true)) return;
|
|
1006
|
+
if (
|
|
1007
|
+
boundedJsonBytes(
|
|
1008
|
+
mapRuntimeFrameDetails(safeFrame, omitOptionalDetails),
|
|
1009
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
1010
|
+
runtimeFrameCollectionLimit(safeFrame),
|
|
1011
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
1012
|
+
4_096,
|
|
1013
|
+
) === Number.POSITIVE_INFINITY
|
|
1014
|
+
) {
|
|
1015
|
+
this.recordProtocolViolation("frame-limit", {
|
|
1016
|
+
reason: "semantic-frame-limit",
|
|
1017
|
+
field: "frame.byteLength",
|
|
1018
|
+
expected: "within-byte-limit",
|
|
1019
|
+
actualType: "oversized",
|
|
1020
|
+
maxByteSize: rawByteLength,
|
|
1021
|
+
limitBytes: MAX_SEMANTIC_FRAME_BYTES,
|
|
1022
|
+
});
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (type === "rpc_chunk") {
|
|
1026
|
+
const chunk = OmpChunkFrameSchema.safeParse(safeFrame);
|
|
1027
|
+
if (!chunk.success) {
|
|
1028
|
+
this.rejectChunk("chunk-schema", {
|
|
1029
|
+
field: "chunk",
|
|
1030
|
+
expected: "valid-chunk-frame",
|
|
1031
|
+
actualType: "object",
|
|
1032
|
+
});
|
|
1033
|
+
} else this.receiveChunk(chunk.data);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (this.chunk) {
|
|
1037
|
+
this.clearChunk();
|
|
1038
|
+
this.recordProtocolViolation("interleaved-chunk", {
|
|
1039
|
+
reason: "frame-interleaved-with-chunk",
|
|
1040
|
+
field: "chunk.sequence",
|
|
1041
|
+
expected: "no-interleaved-frame",
|
|
1042
|
+
actualType: "interleaved",
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
if (type === "rpc_frame_error") {
|
|
1046
|
+
this.recordProtocolViolation("remote-frame-error", {
|
|
1047
|
+
reason: "remote-frame-error",
|
|
1048
|
+
frameType: "rpc_frame_error",
|
|
1049
|
+
field: "frame",
|
|
1050
|
+
expected: "no-remote-frame-error",
|
|
1051
|
+
actualType: "remote-error",
|
|
1052
|
+
});
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (type === "ready") {
|
|
1056
|
+
if (this.readyReceived) {
|
|
1057
|
+
this.recordProtocolViolation("duplicate-ready", {
|
|
1058
|
+
reason: "ready-already-received",
|
|
1059
|
+
frameType: "ready",
|
|
1060
|
+
field: "ready",
|
|
1061
|
+
expected: "single-ready-frame",
|
|
1062
|
+
actualType: "duplicate",
|
|
1063
|
+
});
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const ready = OmpReadyFrameSchema.safeParse(safeFrame);
|
|
1067
|
+
if (!ready.success) {
|
|
1068
|
+
this.recordProtocolViolation("invalid-ready", {
|
|
1069
|
+
reason: "ready-schema",
|
|
1070
|
+
frameType: "ready",
|
|
1071
|
+
field: "ready",
|
|
1072
|
+
expected: "valid-ready-frame",
|
|
1073
|
+
actualType: "object",
|
|
1074
|
+
});
|
|
1075
|
+
} else {
|
|
1076
|
+
this.readyReceived = true;
|
|
1077
|
+
this.resolveReady(ready.data);
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (type === "response") {
|
|
1082
|
+
this.receiveResponse(safeFrame);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const event = OmpRuntimeEventSchema.safeParse(safeFrame);
|
|
1086
|
+
if (!event.success) {
|
|
1087
|
+
this.rejectMatchingToolApproval(safeFrame);
|
|
1088
|
+
if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
|
|
1089
|
+
this.recordProtocolViolation("invalid-event", {
|
|
1090
|
+
...invalidEventDiagnosticMetadata(safeFrame, type),
|
|
1091
|
+
maxByteSize: rawByteLength,
|
|
1092
|
+
});
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (!this.acceptEventState(event.data)) {
|
|
1096
|
+
this.recordProtocolViolation("invalid-event-state", {
|
|
1097
|
+
reason: "event-state-transition",
|
|
1098
|
+
eventType: event.data.type,
|
|
1099
|
+
field: "event.sequence",
|
|
1100
|
+
expected: "valid-event-state-transition",
|
|
1101
|
+
actualType: "out-of-order",
|
|
1102
|
+
maxByteSize: rawByteLength,
|
|
1103
|
+
});
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
this.emit(event.data);
|
|
1107
|
+
if (event.data.type === "prompt_result" && event.data.id) {
|
|
1108
|
+
this.acceptedPromptIds.delete(event.data.id);
|
|
1109
|
+
}
|
|
1110
|
+
if (event.data.type === "turn_end" || event.data.type === "agent_end") {
|
|
1111
|
+
this.turnActive = false;
|
|
1112
|
+
}
|
|
1113
|
+
if (
|
|
1114
|
+
event.data.type === "message_end" ||
|
|
1115
|
+
event.data.type === "turn_end" ||
|
|
1116
|
+
event.data.type === "agent_end"
|
|
1117
|
+
) {
|
|
1118
|
+
this.streamedBlocks.clear();
|
|
1119
|
+
}
|
|
1120
|
+
if (event.data.type === "turn_end") {
|
|
1121
|
+
this.commandTextLength = 0;
|
|
1122
|
+
this.activeToolCallIds.clear();
|
|
1123
|
+
this.announcedToolCallIds.clear();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private rejectMatchingToolApproval(frame: Record<string, unknown>): void {
|
|
1128
|
+
if (frame.type !== "tool_approval_request") return;
|
|
1129
|
+
const { id, toolCallId } = frame;
|
|
1130
|
+
if (!isBoundedToolApprovalId(id) || !isBoundedToolApprovalId(toolCallId)) return;
|
|
1131
|
+
void this.sendFrame({
|
|
1132
|
+
type: "tool_approval_response",
|
|
1133
|
+
id,
|
|
1134
|
+
toolCallId,
|
|
1135
|
+
cancelled: true,
|
|
1136
|
+
}).catch(() => this.fail(new Error("OMP rejected tool approval could not be canceled")));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private acceptEventState(event: z.infer<typeof OmpRuntimeEventSchema>): boolean {
|
|
1140
|
+
if (event.type === "turn_start") {
|
|
1141
|
+
this.turnActive = true;
|
|
1142
|
+
this.streamedBlocks.clear();
|
|
1143
|
+
this.commandTextLength = 0;
|
|
1144
|
+
this.activeToolCallIds.clear();
|
|
1145
|
+
this.announcedToolCallIds.clear();
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
if (event.type === "command_output") {
|
|
1149
|
+
const nextLength = this.commandTextLength + utf8Bytes(event.text ?? "");
|
|
1150
|
+
if (nextLength > MAX_STREAM_TEXT_LENGTH) return false;
|
|
1151
|
+
this.commandTextLength = nextLength;
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
if (event.type === "tool_execution_start") {
|
|
1155
|
+
if (
|
|
1156
|
+
!this.activeToolCallIds.has(event.toolCallId) &&
|
|
1157
|
+
this.activeToolCallIds.size >= MAX_ACTIVE_TOOLS
|
|
1158
|
+
) {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
this.activeToolCallIds.add(event.toolCallId);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
if (event.type === "tool_stream_update") {
|
|
1165
|
+
return (
|
|
1166
|
+
this.activeToolCallIds.has(event.toolCallId) ||
|
|
1167
|
+
this.announcedToolCallIds.has(event.toolCallId)
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
if (event.type === "tool_execution_update") {
|
|
1171
|
+
return this.activeToolCallIds.has(event.toolCallId);
|
|
1172
|
+
}
|
|
1173
|
+
if (event.type === "tool_execution_end") {
|
|
1174
|
+
if (!this.activeToolCallIds.has(event.toolCallId)) return false;
|
|
1175
|
+
this.activeToolCallIds.delete(event.toolCallId);
|
|
1176
|
+
this.announcedToolCallIds.delete(event.toolCallId);
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1179
|
+
if (
|
|
1180
|
+
event.type !== "message_start" &&
|
|
1181
|
+
event.type !== "message_update" &&
|
|
1182
|
+
event.type !== "message_end"
|
|
1183
|
+
) {
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
if (event.message.role !== "assistant") return true;
|
|
1187
|
+
const nextBlocks =
|
|
1188
|
+
event.type === "message_start" ? new Map<number, string>() : new Map(this.streamedBlocks);
|
|
1189
|
+
const content = event.message.content;
|
|
1190
|
+
if (typeof content === "string") {
|
|
1191
|
+
nextBlocks.set(0, content);
|
|
1192
|
+
} else if (Array.isArray(content)) {
|
|
1193
|
+
for (const [index, part] of content.entries()) {
|
|
1194
|
+
if (
|
|
1195
|
+
part.type === "toolCall" &&
|
|
1196
|
+
typeof part.id === "string" &&
|
|
1197
|
+
(this.announcedToolCallIds.has(part.id) ||
|
|
1198
|
+
this.announcedToolCallIds.size < MAX_ACTIVE_TOOLS)
|
|
1199
|
+
) {
|
|
1200
|
+
this.announcedToolCallIds.add(part.id);
|
|
1201
|
+
}
|
|
1202
|
+
const text =
|
|
1203
|
+
part.type === "text" ? part.text : part.type === "thinking" ? part.thinking : undefined;
|
|
1204
|
+
if (text !== undefined) nextBlocks.set(index, text);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const update = event.type === "message_update" ? event.assistantMessageEvent : undefined;
|
|
1208
|
+
if (
|
|
1209
|
+
update?.contentIndex !== undefined &&
|
|
1210
|
+
update.delta !== undefined &&
|
|
1211
|
+
(content === undefined ||
|
|
1212
|
+
(Array.isArray(content) && content[update.contentIndex] === undefined))
|
|
1213
|
+
) {
|
|
1214
|
+
const current = nextBlocks.get(update.contentIndex) ?? "";
|
|
1215
|
+
nextBlocks.set(update.contentIndex, `${current}${update.delta}`);
|
|
1216
|
+
}
|
|
1217
|
+
let totalLength = 0;
|
|
1218
|
+
for (const text of nextBlocks.values()) {
|
|
1219
|
+
totalLength += utf8Bytes(text);
|
|
1220
|
+
if (totalLength > MAX_STREAM_TEXT_LENGTH) return false;
|
|
1221
|
+
}
|
|
1222
|
+
this.streamedBlocks.clear();
|
|
1223
|
+
for (const [index, text] of nextBlocks) this.streamedBlocks.set(index, text);
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private protocolDiagnosticPhase(): OmpProtocolDiagnosticPhase {
|
|
1228
|
+
if (this.closed || this.exited || this.outputSettled) return "closing";
|
|
1229
|
+
if (!this.readyReceived) return "startup";
|
|
1230
|
+
for (const pending of this.pending.values()) {
|
|
1231
|
+
if (pending.command === "negotiate_protocol") return "negotiation";
|
|
1232
|
+
if (pending.command === "prompt") return "active-turn";
|
|
1233
|
+
}
|
|
1234
|
+
if (this.turnActive || this.acceptedPromptIds.size > 0) return "active-turn";
|
|
1235
|
+
return "idle";
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private rejectChunk(
|
|
1239
|
+
reason: OmpProtocolViolationReason,
|
|
1240
|
+
metadata: Omit<
|
|
1241
|
+
OmpProtocolViolationDiagnostic,
|
|
1242
|
+
"category" | "reason" | "occurrenceCount" | "phase" | "frameType"
|
|
1243
|
+
> = {},
|
|
1244
|
+
): void {
|
|
1245
|
+
this.clearChunk();
|
|
1246
|
+
this.recordProtocolViolation("invalid-chunk", {
|
|
1247
|
+
reason,
|
|
1248
|
+
frameType: "rpc_chunk",
|
|
1249
|
+
...metadata,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
private recordProtocolViolation(
|
|
1254
|
+
category: OmpProtocolViolationCategory,
|
|
1255
|
+
metadata: Omit<OmpProtocolViolationDiagnostic, "category" | "occurrenceCount" | "phase">,
|
|
1256
|
+
): void {
|
|
1257
|
+
const phase = this.protocolDiagnosticPhase();
|
|
1258
|
+
if (!this.protocolViolationTimer) {
|
|
1259
|
+
this.emitProtocolViolation({ category, occurrenceCount: 1, phase, ...metadata });
|
|
1260
|
+
this.protocolViolationTimer = setTimeout(
|
|
1261
|
+
() => this.flushProtocolViolations(),
|
|
1262
|
+
PROTOCOL_VIOLATION_COALESCE_MS,
|
|
1263
|
+
);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const key = `${category}:${metadata.reason}` as ProtocolViolationKey;
|
|
1267
|
+
const pending = this.pendingProtocolViolations.get(key);
|
|
1268
|
+
if (!pending) {
|
|
1269
|
+
this.pendingProtocolViolations.set(key, {
|
|
1270
|
+
category,
|
|
1271
|
+
occurrenceCount: 1,
|
|
1272
|
+
phase,
|
|
1273
|
+
...metadata,
|
|
1274
|
+
});
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
pending.occurrenceCount = Math.min(Number.MAX_SAFE_INTEGER, pending.occurrenceCount + 1);
|
|
1278
|
+
pending.maxByteSize =
|
|
1279
|
+
Math.max(pending.maxByteSize ?? 0, metadata.maxByteSize ?? 0) || undefined;
|
|
1280
|
+
pending.reason = metadata.reason;
|
|
1281
|
+
pending.phase = phase;
|
|
1282
|
+
pending.frameType = metadata.frameType;
|
|
1283
|
+
pending.field = metadata.field;
|
|
1284
|
+
pending.expected = metadata.expected;
|
|
1285
|
+
pending.actualType = metadata.actualType;
|
|
1286
|
+
pending.limitBytes = metadata.limitBytes;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
private flushProtocolViolations(): void {
|
|
1290
|
+
if (this.protocolViolationTimer) clearTimeout(this.protocolViolationTimer);
|
|
1291
|
+
this.protocolViolationTimer = null;
|
|
1292
|
+
for (const diagnostic of this.pendingProtocolViolations.values()) {
|
|
1293
|
+
this.emitProtocolViolation(diagnostic);
|
|
1294
|
+
}
|
|
1295
|
+
this.pendingProtocolViolations.clear();
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
private emitProtocolViolation(diagnostic: OmpProtocolViolationDiagnostic): void {
|
|
1299
|
+
try {
|
|
1300
|
+
const reporting = this.reportProtocolViolation(diagnostic);
|
|
1301
|
+
if (reporting) void reporting.catch(() => undefined);
|
|
1302
|
+
} catch {
|
|
1303
|
+
// Diagnostics must never alter transport flow or cleanup.
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
private fail(error: Error): void {
|
|
1308
|
+
if (this.fatalError || this.closed) return;
|
|
1309
|
+
this.fatalError = error;
|
|
1310
|
+
this.rejectReady(error);
|
|
1311
|
+
this.failPending(error);
|
|
1312
|
+
this.emit({ type: "process_exit", error: error.message });
|
|
1313
|
+
void this.close().catch(() => undefined);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private clearChunk(): void {
|
|
1317
|
+
const chunk = this.chunk;
|
|
1318
|
+
if (chunk) clearTimeout(chunk.timer);
|
|
1319
|
+
this.chunk = null;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private failPending(error: Error): void {
|
|
1323
|
+
for (const pending of this.pending.values()) {
|
|
1324
|
+
clearTimeout(pending.timer);
|
|
1325
|
+
pending.reject(error);
|
|
1326
|
+
}
|
|
1327
|
+
this.pending.clear();
|
|
1328
|
+
for (const pending of this.pendingOneWayWrites.values()) {
|
|
1329
|
+
clearTimeout(pending.timer);
|
|
1330
|
+
pending.reject(error);
|
|
1331
|
+
}
|
|
1332
|
+
this.acceptedPromptIds.clear();
|
|
1333
|
+
this.pendingOneWayWrites.clear();
|
|
1334
|
+
this.queuedWrites.clear();
|
|
1335
|
+
this.pendingWriteBytes = 0;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
private emit(event: OmpRpcEvent): void {
|
|
1339
|
+
for (const listener of this.listeners) listener(event);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export function validateReadyMetadata(frame: ReadyFrame): void {
|
|
1344
|
+
const metadata = [
|
|
1345
|
+
frame.protocolVersion,
|
|
1346
|
+
frame.supportedProtocolVersions,
|
|
1347
|
+
frame.maxFrameBytes,
|
|
1348
|
+
frame.maxReassembledFrameBytes,
|
|
1349
|
+
];
|
|
1350
|
+
if (metadata.every((value) => value === undefined)) {
|
|
1351
|
+
throw new OmpPublicError("OMP provider requires OMP RPC protocol v2");
|
|
1352
|
+
}
|
|
1353
|
+
if (metadata.some((value) => value === undefined)) {
|
|
1354
|
+
throw new Error("OMP ready frame contains incomplete protocol metadata");
|
|
1355
|
+
}
|
|
1356
|
+
if (
|
|
1357
|
+
frame.protocolVersion !== 1 ||
|
|
1358
|
+
!frame.supportedProtocolVersions?.includes(1) ||
|
|
1359
|
+
!frame.maxFrameBytes ||
|
|
1360
|
+
frame.maxFrameBytes > MAX_PHYSICAL_FRAME_BYTES ||
|
|
1361
|
+
!frame.maxReassembledFrameBytes ||
|
|
1362
|
+
frame.maxReassembledFrameBytes > MAX_REASSEMBLED_FRAME_BYTES ||
|
|
1363
|
+
frame.maxReassembledFrameBytes < frame.maxFrameBytes
|
|
1364
|
+
) {
|
|
1365
|
+
throw new Error("OMP ready frame advertises unsupported protocol limits");
|
|
1366
|
+
}
|
|
1367
|
+
if (frame.maxFrameBytes < MIN_HOST_TOOL_RESULT_FRAME_BYTES) {
|
|
1368
|
+
throw new Error("OMP ready frame cannot carry terminal host tool results");
|
|
1369
|
+
}
|
|
1370
|
+
if (!frame.supportedProtocolVersions.includes(2)) {
|
|
1371
|
+
throw new OmpPublicError("OMP provider requires OMP RPC protocol v2");
|
|
1372
|
+
}
|
|
1373
|
+
}
|