@pasko70/pibo 2.0.0 → 2.1.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/dist/agent-runtimes/omp/adapter.js +581 -0
- package/dist/agent-runtimes/omp/auth.js +109 -0
- package/dist/agent-runtimes/omp/client.js +460 -0
- package/dist/agent-runtimes/omp/config.js +188 -0
- package/dist/agent-runtimes/omp/history.js +108 -0
- package/dist/agent-runtimes/omp/host-tools.js +201 -0
- package/dist/agent-runtimes/omp/models.js +86 -0
- package/dist/agent-runtimes/omp/process.js +283 -0
- package/dist/agent-runtimes/omp/protocol-types.js +17 -0
- package/dist/agent-runtimes/omp/resource-delivery.js +162 -0
- package/dist/agent-runtimes/omp/thread.js +141 -0
- package/dist/agent-runtimes/omp/turn.js +325 -0
- package/dist/index.js +1 -1
- package/dist/plugins/builtin.js +4 -2
- package/dist/plugins/omp.js +57 -0
- package/package.json +1 -1
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-2.0.0.vsix +0 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { OMP_RPC_CHUNK_PAYLOAD_BYTES, OMP_RPC_MAX_FRAME_BYTES, OMP_RPC_PROTOCOL_VERSION, } from "./protocol-types.js";
|
|
4
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
5
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
|
|
6
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
7
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_MAX_PENDING_REQUESTS = 128;
|
|
9
|
+
const DEFAULT_MAX_STDERR_BYTES = 256 * 1024;
|
|
10
|
+
export class OmpRpcClientError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message, options = {}) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "OmpRpcClientError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class OmpRpcResponseError extends Error {
|
|
19
|
+
command;
|
|
20
|
+
error;
|
|
21
|
+
errorCode;
|
|
22
|
+
constructor(command, error, errorCode, options = {}) {
|
|
23
|
+
super(`OMP RPC command "${command}" failed: ${error}`, options);
|
|
24
|
+
this.command = command;
|
|
25
|
+
this.error = error;
|
|
26
|
+
this.errorCode = errorCode;
|
|
27
|
+
this.name = "OmpRpcResponseError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function deferred() {
|
|
31
|
+
const d = {};
|
|
32
|
+
d.promise = new Promise((resolve, reject) => {
|
|
33
|
+
d.resolve = resolve;
|
|
34
|
+
d.reject = reject;
|
|
35
|
+
});
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
function isOmpRpcSideChannel(frame) {
|
|
39
|
+
const type = frame.type;
|
|
40
|
+
return type === "host_tool_result"
|
|
41
|
+
|| type === "host_tool_update"
|
|
42
|
+
|| type === "host_uri_result"
|
|
43
|
+
|| type === "extension_ui_response";
|
|
44
|
+
}
|
|
45
|
+
function isRecord(value) {
|
|
46
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
function parseReadyFrame(value) {
|
|
49
|
+
if (!isRecord(value) || value.type !== "ready") {
|
|
50
|
+
throw new OmpRpcClientError("protocol_error", "OMP child did not emit a ready frame on startup.");
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export class OmpRpcClient {
|
|
55
|
+
options;
|
|
56
|
+
child;
|
|
57
|
+
stdoutBuffer = Buffer.alloc(0);
|
|
58
|
+
state = "starting";
|
|
59
|
+
protocolVersion = 1;
|
|
60
|
+
pending = new Map();
|
|
61
|
+
chunks = new Map();
|
|
62
|
+
frameListeners = new Set();
|
|
63
|
+
sideChannelListeners = new Set();
|
|
64
|
+
diagnosticListeners = new Set();
|
|
65
|
+
stderrBytes = 0;
|
|
66
|
+
startupTimeoutMs;
|
|
67
|
+
requestTimeoutMs;
|
|
68
|
+
shutdownTimeoutMs;
|
|
69
|
+
maxMessageBytes;
|
|
70
|
+
maxPendingRequests;
|
|
71
|
+
maxStderrBytes;
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
75
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
76
|
+
this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
77
|
+
this.maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
78
|
+
this.maxPendingRequests = options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS;
|
|
79
|
+
this.maxStderrBytes = options.maxStderrBytes ?? DEFAULT_MAX_STDERR_BYTES;
|
|
80
|
+
}
|
|
81
|
+
get snapshot() {
|
|
82
|
+
return {
|
|
83
|
+
state: this.state,
|
|
84
|
+
ready: this.state === "ready",
|
|
85
|
+
pendingRequests: this.pending.size,
|
|
86
|
+
protocolVersion: this.protocolVersion,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
get process() {
|
|
90
|
+
return this.child;
|
|
91
|
+
}
|
|
92
|
+
get connected() {
|
|
93
|
+
return this.state === "ready";
|
|
94
|
+
}
|
|
95
|
+
subscribeFrames(listener) {
|
|
96
|
+
this.frameListeners.add(listener);
|
|
97
|
+
return () => this.frameListeners.delete(listener);
|
|
98
|
+
}
|
|
99
|
+
subscribeSideChannels(listener) {
|
|
100
|
+
this.sideChannelListeners.add(listener);
|
|
101
|
+
return () => this.sideChannelListeners.delete(listener);
|
|
102
|
+
}
|
|
103
|
+
subscribeDiagnostics(listener) {
|
|
104
|
+
this.diagnosticListeners.add(listener);
|
|
105
|
+
return () => this.diagnosticListeners.delete(listener);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Redact likely credential material before surfacing a diagnostic, so OMP
|
|
109
|
+
* stderr (which may echo prompts or provider keys) cannot leak secrets.
|
|
110
|
+
*/
|
|
111
|
+
static redactDiagnostic(message) {
|
|
112
|
+
return message
|
|
113
|
+
.replace(/\b(?:sk|rk|pk|ghp|github_pat)[-_][A-Za-z0-9._~+/-]{8,}\b/g, "[redacted]")
|
|
114
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
115
|
+
.replace(/\b(?:api[_-]?key|secret|token|password)\b(?=[:=]\s*)\s*[^\s,;]+/gi, "$1 [redacted]");
|
|
116
|
+
}
|
|
117
|
+
emitDiagnostic(message) {
|
|
118
|
+
for (const listener of this.diagnosticListeners)
|
|
119
|
+
listener(OmpRpcClient.redactDiagnostic(message));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Spawn the OMP child in `--mode rpc` and wait for the `ready` frame, then
|
|
123
|
+
* negotiate protocol v2 (confirms frame encoder upgrade for large frames).
|
|
124
|
+
*/
|
|
125
|
+
async connect(command, options) {
|
|
126
|
+
if (this.state === "ready")
|
|
127
|
+
return;
|
|
128
|
+
const ready = deferred();
|
|
129
|
+
let child;
|
|
130
|
+
try {
|
|
131
|
+
child = spawn(command[0], command.slice(1), {
|
|
132
|
+
cwd: options.cwd,
|
|
133
|
+
env: options.env,
|
|
134
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
this.state = "failed";
|
|
139
|
+
throw new OmpRpcClientError("spawn_failed", `Failed to spawn OMP child: ${String(error)}`, { cause: error });
|
|
140
|
+
}
|
|
141
|
+
this.child = child;
|
|
142
|
+
let startupTimer;
|
|
143
|
+
child.stderr.on("data", (chunk) => {
|
|
144
|
+
this.stderrBytes += chunk.length;
|
|
145
|
+
if (this.stderrBytes <= this.maxStderrBytes) {
|
|
146
|
+
this.emitDiagnostic(chunk.toString("utf8"));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
child.once("error", (error) => {
|
|
150
|
+
const code = error.code === "ENOENT" ? "spawn_failed" : "process_exited";
|
|
151
|
+
this.state = "failed";
|
|
152
|
+
ready.reject(new OmpRpcClientError(code, `OMP child process error: ${error.message}`, { cause: error }));
|
|
153
|
+
});
|
|
154
|
+
child.once("close", (code, signal) => {
|
|
155
|
+
this.state = "closed";
|
|
156
|
+
this.rejectAll(new OmpRpcClientError("process_exited", `OMP child process exited (code=${String(code)}, signal=${String(signal)})`));
|
|
157
|
+
ready.reject(new OmpRpcClientError("process_exited", "OMP child process exited before becoming ready."));
|
|
158
|
+
});
|
|
159
|
+
child.stdout.on("data", (chunk) => this.onStdoutData(Buffer.from(chunk)));
|
|
160
|
+
// Wait for ready frame.
|
|
161
|
+
const unsubscribe = this.subscribeFrames((frame) => {
|
|
162
|
+
if (frame.type === "ready") {
|
|
163
|
+
unsubscribe();
|
|
164
|
+
ready.resolve(frame);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
startupTimer = setTimeout(() => {
|
|
168
|
+
ready.reject(new OmpRpcClientError("timeout", "Timed out waiting for the OMP ready frame."));
|
|
169
|
+
}, this.startupTimeoutMs);
|
|
170
|
+
this.state = "initializing";
|
|
171
|
+
let startup;
|
|
172
|
+
try {
|
|
173
|
+
startup = await ready.promise;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
this.state = "failed";
|
|
177
|
+
this.closeProcess();
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
if (startupTimer)
|
|
182
|
+
clearTimeout(startupTimer);
|
|
183
|
+
}
|
|
184
|
+
// Mark ready before negotiation so the client can send the handshake.
|
|
185
|
+
this.state = "ready";
|
|
186
|
+
this.protocolVersion = startup.protocolVersion;
|
|
187
|
+
try {
|
|
188
|
+
await this.request({ type: "negotiate_protocol", protocolVersion: OMP_RPC_PROTOCOL_VERSION }, "negotiate_protocol");
|
|
189
|
+
this.protocolVersion = OMP_RPC_PROTOCOL_VERSION;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
this.state = "failed";
|
|
193
|
+
this.closeProcess();
|
|
194
|
+
throw new OmpRpcClientError("protocol_error", `OMP protocol negotiation failed: ${String(error)}`, { cause: error });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
onStdoutData(chunk) {
|
|
198
|
+
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
|
|
199
|
+
let newlineIndex;
|
|
200
|
+
while ((newlineIndex = this.stdoutBuffer.indexOf(0x0a)) !== -1) {
|
|
201
|
+
const line = this.stdoutBuffer.subarray(0, newlineIndex).toString("utf8").trim();
|
|
202
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(newlineIndex + 1);
|
|
203
|
+
if (line.length === 0)
|
|
204
|
+
continue;
|
|
205
|
+
this.handleLine(line);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
handleLine(line) {
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(line);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
this.emitDiagnostic(`OMP emitted a malformed JSON line on stdout (ignored).`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!isRecord(parsed)) {
|
|
218
|
+
this.emitDiagnostic(`OMP emitted a non-object JSON line on stdout (ignored).`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Reassemble oversized chunked frames first.
|
|
222
|
+
if (parsed.type === "rpc_chunk") {
|
|
223
|
+
const reassembled = this.consumeChunk(parsed);
|
|
224
|
+
if (reassembled === undefined)
|
|
225
|
+
return; // waiting for more chunks
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(reassembled);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
this.emitDiagnostic(`OMP chunk reassembly produced invalid JSON (ignored).`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const frame = parsed;
|
|
235
|
+
for (const listener of this.frameListeners)
|
|
236
|
+
listener(frame);
|
|
237
|
+
if (isOmpRpcSideChannel(frame)) {
|
|
238
|
+
const sideChannel = frame;
|
|
239
|
+
for (const listener of this.sideChannelListeners)
|
|
240
|
+
listener(sideChannel);
|
|
241
|
+
}
|
|
242
|
+
if (this.isResponse(frame)) {
|
|
243
|
+
this.resolveResponse(frame);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
isSideChannel(frame) {
|
|
247
|
+
const type = frame.type;
|
|
248
|
+
return (type === "host_tool_result" ||
|
|
249
|
+
type === "host_tool_update" ||
|
|
250
|
+
type === "host_uri_result" ||
|
|
251
|
+
type === "extension_ui_response");
|
|
252
|
+
}
|
|
253
|
+
isResponse(frame) {
|
|
254
|
+
return frame.type === "response";
|
|
255
|
+
}
|
|
256
|
+
consumeChunk(frame) {
|
|
257
|
+
// Validate frame bounds first so an out-of-range index can never create a
|
|
258
|
+
// sparse array that Buffer.concat later turns into an uncaught TypeError.
|
|
259
|
+
if (!Number.isSafeInteger(frame.index) || frame.index < 0 || frame.index >= frame.count) {
|
|
260
|
+
this.chunks.delete(frame.chunkId);
|
|
261
|
+
this.emitDiagnostic(`OMP chunk frame has an invalid index ${frame.index} (dropped).`);
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
let decoded;
|
|
265
|
+
try {
|
|
266
|
+
decoded = Buffer.from(frame.data, "base64");
|
|
267
|
+
// Reject invalid base64 (empty/garbage) before it becomes a hole.
|
|
268
|
+
if (frame.data.length === 0 && frame.byteLength > 0) {
|
|
269
|
+
this.chunks.delete(frame.chunkId);
|
|
270
|
+
this.emitDiagnostic(`OMP chunk frame carries empty base64 payload (dropped).`);
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
this.chunks.delete(frame.chunkId);
|
|
276
|
+
this.emitDiagnostic(`OMP chunk frame base64 decode failed (dropped).`);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const existing = this.chunks.get(frame.chunkId);
|
|
280
|
+
if (existing) {
|
|
281
|
+
if (existing.count !== frame.count || existing.byteLength !== frame.byteLength) {
|
|
282
|
+
this.chunks.delete(frame.chunkId);
|
|
283
|
+
this.emitDiagnostic(`OMP chunk frame metadata mismatch (dropped).`);
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
// Must be the next in-order chunk; reject duplicates/holes/out-of-order.
|
|
287
|
+
if (frame.index !== existing.parts.length) {
|
|
288
|
+
this.chunks.delete(frame.chunkId);
|
|
289
|
+
this.emitDiagnostic(`OMP chunk frame arrived out of order (expected index ${existing.parts.length}, got ${frame.index}); dropped.`);
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
existing.parts.push(decoded);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
if (frame.index !== 0) {
|
|
296
|
+
this.emitDiagnostic(`OMP chunk frame sequence did not start at index 0 (dropped).`);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
this.chunks.set(frame.chunkId, { count: frame.count, byteLength: frame.byteLength, parts: [decoded] });
|
|
300
|
+
}
|
|
301
|
+
const acc = this.chunks.get(frame.chunkId);
|
|
302
|
+
if (acc.parts.length < acc.count)
|
|
303
|
+
return undefined;
|
|
304
|
+
this.chunks.delete(frame.chunkId);
|
|
305
|
+
const full = Buffer.concat(acc.parts);
|
|
306
|
+
if (full.length !== acc.byteLength) {
|
|
307
|
+
this.emitDiagnostic(`OMP chunk reassembly length mismatch (dropped).`);
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
return full.toString("utf8");
|
|
311
|
+
}
|
|
312
|
+
resolveResponse(response) {
|
|
313
|
+
const id = response.id;
|
|
314
|
+
if (id === undefined) {
|
|
315
|
+
// Responses without an id that still carry command info are protocol
|
|
316
|
+
// telemetry; emit as diagnostic rather than dropping silently.
|
|
317
|
+
this.emitDiagnostic(`OMP response without an id (command=${response.command}).`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const entry = this.pending.get(id);
|
|
321
|
+
if (!entry)
|
|
322
|
+
return; // late/superseded response
|
|
323
|
+
this.pending.delete(id);
|
|
324
|
+
clearTimeout(entry.timer);
|
|
325
|
+
entry.resolve(response);
|
|
326
|
+
}
|
|
327
|
+
rejectAll(error) {
|
|
328
|
+
for (const [id, entry] of Array.from(this.pending.entries())) {
|
|
329
|
+
this.pending.delete(id);
|
|
330
|
+
clearTimeout(entry.timer);
|
|
331
|
+
entry.reject(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Send a command and await its correlated response. Always matches on `id`
|
|
336
|
+
* (never queue order) — OMP dispatches `bash` concurrently and side-channel
|
|
337
|
+
* frames overtake the serial queue.
|
|
338
|
+
*/
|
|
339
|
+
async request(command, label) {
|
|
340
|
+
if (this.state !== "ready") {
|
|
341
|
+
throw new OmpRpcClientError("closed", `Cannot send OMP command "${label}": client is not ready.`);
|
|
342
|
+
}
|
|
343
|
+
if (this.pending.size >= this.maxPendingRequests) {
|
|
344
|
+
throw new OmpRpcClientError("pending_limit", "Too many pending OMP RPC requests.");
|
|
345
|
+
}
|
|
346
|
+
const id = (command.id ?? randomUUID());
|
|
347
|
+
const withId = { ...command, id };
|
|
348
|
+
let serialized;
|
|
349
|
+
try {
|
|
350
|
+
serialized = JSON.stringify(withId);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
throw new OmpRpcClientError("serialization_error", `Failed to serialize OMP command "${label}": ${String(error)}`);
|
|
354
|
+
}
|
|
355
|
+
if (Buffer.byteLength(serialized, "utf8") > this.maxMessageBytes) {
|
|
356
|
+
throw new OmpRpcClientError("message_too_large", `OMP command "${label}" exceeds the message size limit.`);
|
|
357
|
+
}
|
|
358
|
+
const response = deferred();
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
this.pending.delete(id);
|
|
361
|
+
response.reject(new OmpRpcClientError("timeout", `OMP command "${label}" timed out.`));
|
|
362
|
+
}, this.requestTimeoutMs);
|
|
363
|
+
this.pending.set(id, {
|
|
364
|
+
command: command.type,
|
|
365
|
+
resolve: response.resolve,
|
|
366
|
+
reject: response.reject,
|
|
367
|
+
timer,
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await this.writeLine(serialized);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
this.pending.delete(id);
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
response.reject(error);
|
|
376
|
+
}
|
|
377
|
+
const result = await response.promise;
|
|
378
|
+
if (result["success"] === false) {
|
|
379
|
+
const err = result;
|
|
380
|
+
throw new OmpRpcResponseError(label, err.error ?? "unknown error", err.code);
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
async writeLine(line) {
|
|
385
|
+
return await new Promise((resolveWrite, rejectWrite) => {
|
|
386
|
+
if (!this.child || this.child.stdin.destroyed) {
|
|
387
|
+
rejectWrite(new OmpRpcClientError("write_failed", "OMP child stdin is closed."));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this.child.stdin.write(`${line}\n`, (error) => {
|
|
391
|
+
if (error)
|
|
392
|
+
rejectWrite(new OmpRpcClientError("write_failed", `Failed to write to OMP child: ${error.message}`, { cause: error }));
|
|
393
|
+
else
|
|
394
|
+
resolveWrite();
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
get stderr() {
|
|
399
|
+
return []; // diagnostics already streamed via listeners; retained for symmetry
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Send a fire-and-forget side-channel frame to OMP (host_tool_result,
|
|
403
|
+
* host_tool_update, host_uri_result, extension_ui_response). These overtake
|
|
404
|
+
* the serial command queue and do not receive a correlated `response`.
|
|
405
|
+
*/
|
|
406
|
+
async sendSideChannel(frame, label) {
|
|
407
|
+
if (this.state !== "ready") {
|
|
408
|
+
throw new OmpRpcClientError("closed", `Cannot send OMP side-channel frame "${label}": client is not ready.`);
|
|
409
|
+
}
|
|
410
|
+
let serialized;
|
|
411
|
+
try {
|
|
412
|
+
serialized = JSON.stringify(frame);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
throw new OmpRpcClientError("serialization_error", `Failed to serialize OMP frame "${label}": ${String(error)}`);
|
|
416
|
+
}
|
|
417
|
+
if (Buffer.byteLength(serialized, "utf8") > this.maxMessageBytes) {
|
|
418
|
+
throw new OmpRpcClientError("message_too_large", `OMP frame "${label}" exceeds the message size limit.`);
|
|
419
|
+
}
|
|
420
|
+
await this.writeLine(serialized);
|
|
421
|
+
}
|
|
422
|
+
async close() {
|
|
423
|
+
if (this.state === "ready") {
|
|
424
|
+
this.state = "closing";
|
|
425
|
+
}
|
|
426
|
+
this.closeProcess();
|
|
427
|
+
}
|
|
428
|
+
closeProcess() {
|
|
429
|
+
if (this.child && !this.child.killed) {
|
|
430
|
+
try {
|
|
431
|
+
this.child.kill("SIGTERM");
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// ignore
|
|
435
|
+
}
|
|
436
|
+
const child = this.child;
|
|
437
|
+
// Ensure the child is reaped even on platforms where SIGTERM is
|
|
438
|
+
// ignored (freeing any cwd lock for temp cleanup).
|
|
439
|
+
const timer = setTimeout(() => {
|
|
440
|
+
if (child && !child.killed) {
|
|
441
|
+
try {
|
|
442
|
+
child.kill("SIGKILL");
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// ignore
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}, this.shutdownTimeoutMs);
|
|
449
|
+
timer.unref();
|
|
450
|
+
}
|
|
451
|
+
this.state = "closed";
|
|
452
|
+
this.rejectAll(new OmpRpcClientError("aborted", "OMP client closed."));
|
|
453
|
+
}
|
|
454
|
+
dispose() {
|
|
455
|
+
this.closeProcess();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** Convenience for oversized-frames logic parity; retained as a documented limit. */
|
|
459
|
+
export const OMP_MAX_CHUNK_PAYLOAD_BYTES = OMP_RPC_CHUNK_PAYLOAD_BYTES;
|
|
460
|
+
export const OMP_DEFAULT_MAX_FRAME_BYTES = OMP_RPC_MAX_FRAME_BYTES;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { piboHomePath } from "../../core/pibo-home.js";
|
|
2
|
+
const MAX_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
3
|
+
const MAX_AUTH_LOGIN_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
4
|
+
const ENVIRONMENT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
5
|
+
const RESERVED_ENVIRONMENT_KEYS = new Set([
|
|
6
|
+
"PI_CODING_AGENT_DIR",
|
|
7
|
+
"PI_CONFIG_DIR",
|
|
8
|
+
"OMP_PROFILE",
|
|
9
|
+
"PI_PROFILE",
|
|
10
|
+
"HOME",
|
|
11
|
+
"USERPROFILE",
|
|
12
|
+
"XDG_CACHE_HOME",
|
|
13
|
+
"XDG_CONFIG_HOME",
|
|
14
|
+
"XDG_DATA_HOME",
|
|
15
|
+
"XDG_STATE_HOME",
|
|
16
|
+
"TMP",
|
|
17
|
+
"TEMP",
|
|
18
|
+
"TMPDIR",
|
|
19
|
+
"NODE_OPTIONS",
|
|
20
|
+
"LD_PRELOAD",
|
|
21
|
+
"BASH_ENV",
|
|
22
|
+
"ENV",
|
|
23
|
+
"PYTHONHOME",
|
|
24
|
+
"PYTHONPATH",
|
|
25
|
+
"RUBYOPT",
|
|
26
|
+
"PERL5OPT",
|
|
27
|
+
"JAVA_TOOL_OPTIONS",
|
|
28
|
+
"_JAVA_OPTIONS",
|
|
29
|
+
]);
|
|
30
|
+
export const DEFAULT_OMP_ENVIRONMENT_ALLOWLIST = [
|
|
31
|
+
"PATH",
|
|
32
|
+
"PATHEXT",
|
|
33
|
+
"SystemRoot",
|
|
34
|
+
"WINDIR",
|
|
35
|
+
"COMSPEC",
|
|
36
|
+
"LANG",
|
|
37
|
+
"LC_ALL",
|
|
38
|
+
"LC_CTYPE",
|
|
39
|
+
"TZ",
|
|
40
|
+
"HTTP_PROXY",
|
|
41
|
+
"HTTPS_PROXY",
|
|
42
|
+
"NO_PROXY",
|
|
43
|
+
"ALL_PROXY",
|
|
44
|
+
"SSL_CERT_FILE",
|
|
45
|
+
"SSL_CERT_DIR",
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Pibo does not know the real key names OMP's model catalog reads (it supports
|
|
49
|
+
* many providers and custom `models.yml` baseUrl/apiKey pairs). We allow an
|
|
50
|
+
* operator to add the specific provider keys (e.g. `OPENAI_API_KEY`,
|
|
51
|
+
* `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`) plus any custom
|
|
52
|
+
* keys via the config `apiKeyEnv` allowlist. These names are validated at parse
|
|
53
|
+
* time so a typo fails fast.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_OMP_API_KEY_ENVIRONMENT_KEYS = [
|
|
56
|
+
"OPENAI_API_KEY",
|
|
57
|
+
"ANTHROPIC_API_KEY",
|
|
58
|
+
"GEMINI_API_KEY",
|
|
59
|
+
"DEEPSEEK_API_KEY",
|
|
60
|
+
"OPENROUTER_API_KEY",
|
|
61
|
+
"GROQ_API_KEY",
|
|
62
|
+
"MISTRAL_API_KEY",
|
|
63
|
+
"COHERE_API_KEY",
|
|
64
|
+
"PERPLEXITY_API_KEY",
|
|
65
|
+
"XAI_API_KEY",
|
|
66
|
+
"OMP_DEFAULT_API_KEY",
|
|
67
|
+
];
|
|
68
|
+
export const OMP_RUNTIME_CONFIG_SCHEMA = {
|
|
69
|
+
type: "object",
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
properties: {
|
|
72
|
+
bunExecutable: { type: "string", minLength: 1, default: "bun" },
|
|
73
|
+
ompEntry: { type: "string", minLength: 1 },
|
|
74
|
+
homeRoot: { type: "string", minLength: 1 },
|
|
75
|
+
environmentAllowlist: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string", pattern: ENVIRONMENT_KEY_PATTERN.source },
|
|
78
|
+
uniqueItems: true,
|
|
79
|
+
},
|
|
80
|
+
apiKeyEnvironment: {
|
|
81
|
+
type: "array",
|
|
82
|
+
items: { type: "string", pattern: ENVIRONMENT_KEY_PATTERN.source },
|
|
83
|
+
uniqueItems: true,
|
|
84
|
+
},
|
|
85
|
+
defaultProvider: { type: "string", minLength: 1 },
|
|
86
|
+
defaultModel: { type: "string", minLength: 1 },
|
|
87
|
+
diagnosticTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, default: 5_000 },
|
|
88
|
+
startupTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, default: 15_000 },
|
|
89
|
+
requestTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, default: 180_000 },
|
|
90
|
+
authLoginTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_AUTH_LOGIN_TIMEOUT_MS, default: 15 * 60 * 1_000 },
|
|
91
|
+
shutdownTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, default: 2_000 },
|
|
92
|
+
killTimeoutMs: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, default: 500 },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
export function defaultOmpRuntimeConfig() {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
const cfg = {};
|
|
98
|
+
return {
|
|
99
|
+
bunExecutable: "bun",
|
|
100
|
+
ompEntry: "", // operator must supply the OMP install path
|
|
101
|
+
homeRoot: piboHomePath("agent-runtimes", "omp"),
|
|
102
|
+
environmentAllowlist: [...DEFAULT_OMP_ENVIRONMENT_ALLOWLIST],
|
|
103
|
+
apiKeyEnvironment: [...DEFAULT_OMP_API_KEY_ENVIRONMENT_KEYS],
|
|
104
|
+
diagnosticTimeoutMs: 5_000,
|
|
105
|
+
startupTimeoutMs: 15_000,
|
|
106
|
+
requestTimeoutMs: 180_000,
|
|
107
|
+
authLoginTimeoutMs: 15 * 60 * 1_000,
|
|
108
|
+
shutdownTimeoutMs: 2_000,
|
|
109
|
+
killTimeoutMs: 500,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function timeout(value, fallback, label, maximum = MAX_TIMEOUT_MS) {
|
|
113
|
+
const selected = value ?? fallback;
|
|
114
|
+
if (!Number.isSafeInteger(selected) || Number(selected) <= 0 || Number(selected) > maximum) {
|
|
115
|
+
throw new Error(`${label} must be a positive integer no greater than ${maximum}`);
|
|
116
|
+
}
|
|
117
|
+
return Number(selected);
|
|
118
|
+
}
|
|
119
|
+
function stringAllowlist(value, fallback, label, reject) {
|
|
120
|
+
const selected = value ?? fallback;
|
|
121
|
+
if (!Array.isArray(selected))
|
|
122
|
+
throw new Error(`${label} must be an array of environment variable names`);
|
|
123
|
+
const result = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const entry of selected) {
|
|
126
|
+
if (typeof entry !== "string" || !ENVIRONMENT_KEY_PATTERN.test(entry)) {
|
|
127
|
+
throw new Error(`${label} entries must be valid environment variable names`);
|
|
128
|
+
}
|
|
129
|
+
const canonical = entry.toUpperCase();
|
|
130
|
+
if (reject?.has(canonical) || canonical.startsWith("DYLD_")) {
|
|
131
|
+
throw new Error(`${label} may not include reserved key "${entry}"`);
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(canonical))
|
|
134
|
+
throw new Error(`${label} contains duplicate key "${entry}"`);
|
|
135
|
+
seen.add(canonical);
|
|
136
|
+
result.push(entry);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
function nonEmptyString(value, fallback, label) {
|
|
141
|
+
const selected = value ?? fallback;
|
|
142
|
+
if (typeof selected !== "string" || selected.trim().length === 0) {
|
|
143
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
144
|
+
}
|
|
145
|
+
return selected.trim();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the OMP CLI entry. An empty value means "not yet configured" (the
|
|
149
|
+
* operator supplies it via runtime-instance config); diagnose() surfaces that.
|
|
150
|
+
* A non-empty value MUST be an absolute path — resolving it against the session
|
|
151
|
+
* workspace would silently target the wrong executable.
|
|
152
|
+
*/
|
|
153
|
+
function absolutePathOrEmpty(value, label) {
|
|
154
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
const candidate = value.trim();
|
|
158
|
+
if (!(candidate.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(candidate))) {
|
|
159
|
+
throw new Error(label + " must be an absolute path to the OMP CLI entry; got \"" + candidate + "\"");
|
|
160
|
+
}
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
export function parseOmpRuntimeConfig(value) {
|
|
164
|
+
const record = value ?? {};
|
|
165
|
+
const provider = optionalString(record.defaultProvider);
|
|
166
|
+
const model = optionalString(record.defaultModel);
|
|
167
|
+
const config = {
|
|
168
|
+
bunExecutable: nonEmptyString(record.bunExecutable, "bun", "bunExecutable"),
|
|
169
|
+
ompEntry: absolutePathOrEmpty(record.ompEntry, "ompEntry"),
|
|
170
|
+
homeRoot: nonEmptyString(record.homeRoot, piboHomePath("agent-runtimes", "omp"), "homeRoot"),
|
|
171
|
+
environmentAllowlist: stringAllowlist(record.environmentAllowlist, DEFAULT_OMP_ENVIRONMENT_ALLOWLIST, "environmentAllowlist", RESERVED_ENVIRONMENT_KEYS),
|
|
172
|
+
apiKeyEnvironment: stringAllowlist(record.apiKeyEnvironment, DEFAULT_OMP_API_KEY_ENVIRONMENT_KEYS, "apiKeyEnvironment", RESERVED_ENVIRONMENT_KEYS),
|
|
173
|
+
diagnosticTimeoutMs: timeout(record.diagnosticTimeoutMs, 5_000, "diagnosticTimeoutMs"),
|
|
174
|
+
startupTimeoutMs: timeout(record.startupTimeoutMs, 15_000, "startupTimeoutMs"),
|
|
175
|
+
requestTimeoutMs: timeout(record.requestTimeoutMs, 180_000, "requestTimeoutMs"),
|
|
176
|
+
authLoginTimeoutMs: timeout(record.authLoginTimeoutMs, 15 * 60 * 1_000, "authLoginTimeoutMs", MAX_AUTH_LOGIN_TIMEOUT_MS),
|
|
177
|
+
shutdownTimeoutMs: timeout(record.shutdownTimeoutMs, 2_000, "shutdownTimeoutMs"),
|
|
178
|
+
killTimeoutMs: timeout(record.killTimeoutMs, 500, "killTimeoutMs"),
|
|
179
|
+
};
|
|
180
|
+
if (provider !== undefined)
|
|
181
|
+
config.defaultProvider = provider;
|
|
182
|
+
if (model !== undefined)
|
|
183
|
+
config.defaultModel = model;
|
|
184
|
+
return config;
|
|
185
|
+
}
|
|
186
|
+
function optionalString(value) {
|
|
187
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
188
|
+
}
|