@omercnet/paseo-omp 0.2.1-next.72.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/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/SUPPORT.md +42 -0
- package/TESTING.md +150 -0
- package/client/composer-pill-settings.tsx +157 -0
- package/client/hub-icon.tsx +12 -0
- package/client/hub-popover.tsx +132 -0
- package/client/hub-status.ts +29 -0
- package/client/mcp-authorization.tsx +168 -0
- package/client/mcp-popover.tsx +155 -0
- package/client/memory-panel.tsx +76 -0
- package/client/memory-popover.tsx +74 -0
- package/client/omp-config-surface.tsx +1433 -0
- package/client/omp-doc-links.ts +117 -0
- package/client/omp-plugin-manager.tsx +1004 -0
- package/client/omp-store-picker.tsx +89 -0
- package/client/omp-store-state.ts +45 -0
- package/client/provider-diagnostics-state.ts +262 -0
- package/client/provider-icon.tsx +27 -0
- package/client/provider-image.tsx +66 -0
- package/client/quota-popover.tsx +155 -0
- package/client/quota-state.ts +140 -0
- package/client/sessions-popover.tsx +78 -0
- package/docs/alpha-release-checklist.md +68 -0
- package/docs/configuration.md +126 -0
- package/docs/core-provider-issue-audit.md +108 -0
- package/docs/images/mcp-authorization-compact.png +0 -0
- package/docs/images/mcp-controls-wide.png +0 -0
- package/docs/images/plugin-manager.png +0 -0
- package/docs/images/workspace-settings.png +0 -0
- package/docs/installation.md +67 -0
- package/index.client.tsx +488 -0
- package/index.server.ts +81 -0
- package/package.json +84 -0
- package/paseo-plugin.json +5 -0
- package/scripts/prepare-dependencies.mjs +20 -0
- package/server/hub.ts +145 -0
- package/server/mcp-browser.ts +95 -0
- package/server/memory.ts +86 -0
- package/server/mutation-queue.ts +12 -0
- package/server/omp-config.ts +135 -0
- package/server/omp-plugins.ts +676 -0
- package/server/omp-settings.ts +499 -0
- package/server/paths.ts +181 -0
- package/server/provider/catalog.ts +172 -0
- package/server/provider/config-normalization.ts +148 -0
- package/server/provider/connection.ts +1196 -0
- package/server/provider/host-tools.ts +777 -0
- package/server/provider/image.ts +143 -0
- package/server/provider/mcp-transport.ts +394 -0
- package/server/provider/omp-rpc.ts +2806 -0
- package/server/provider/omp.svg +5 -0
- package/server/provider/profile-providers.ts +249 -0
- package/server/provider/provider-options.ts +27 -0
- package/server/provider/registration.ts +162 -0
- package/server/provider/security.ts +317 -0
- package/server/provider/session-descriptors.ts +736 -0
- package/server/provider/session.ts +4796 -0
- package/server/provider/settings.ts +78 -0
- package/server/provider/subsessions.ts +850 -0
- package/server/provider/timeline-projector.ts +1801 -0
- package/server/provider-diagnostics.ts +1143 -0
- package/server/quota.ts +55 -0
- package/server/sessions.ts +58 -0
- package/shared/composer-pill-settings.ts +28 -0
- package/shared/hub.ts +43 -0
- package/shared/mcp.ts +47 -0
- package/shared/memory.ts +24 -0
- package/shared/omp-config.ts +85 -0
- package/shared/omp-plugins.ts +264 -0
- package/shared/omp-settings.ts +214 -0
- package/shared/omp-store.ts +58 -0
- package/shared/provider-diagnostics.ts +126 -0
- package/shared/provider-image.ts +160 -0
- package/shared/quota.ts +23 -0
- package/shared/sessions.ts +24 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants, type Stats } from "node:fs";
|
|
3
|
+
import { access, type FileHandle, lstat, open, readdir, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import type { RpcInput } from "@getpaseo/plugin";
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { type OmpConfig, OmpConfigSchema } from "../shared/omp-config";
|
|
10
|
+
import type {
|
|
11
|
+
getOmpProviderHealth,
|
|
12
|
+
OmpLspDiagnostics,
|
|
13
|
+
OmpMcpDiagnostics,
|
|
14
|
+
OmpProcessDiagnostics,
|
|
15
|
+
OmpProviderHealth,
|
|
16
|
+
OmpVersion,
|
|
17
|
+
OmpVersionStatus,
|
|
18
|
+
PathState,
|
|
19
|
+
} from "../shared/provider-diagnostics";
|
|
20
|
+
import { currentOmpEnvironment, ompAgentDir, ompDataDir, ompSessionDir } from "./paths";
|
|
21
|
+
|
|
22
|
+
const VERSION_TIMEOUT_MS = 3_000;
|
|
23
|
+
const HELP_TIMEOUT_MS = 3_000;
|
|
24
|
+
const KILL_GRACE_MS = 2_000;
|
|
25
|
+
const MAX_VERSION_BYTES = 2_048;
|
|
26
|
+
const MAX_HELP_BYTES = 65_536;
|
|
27
|
+
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
28
|
+
const MAX_MCP_MANIFEST_BYTES = 64 * 1024;
|
|
29
|
+
const HEALTH_CACHE_TTL_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
const AGENT_DB_FILENAME = "agent.db";
|
|
32
|
+
const HISTORY_DB_FILENAME = "history.db";
|
|
33
|
+
// Matches the `--session-dir` default omp documents in its own `--help` output and the layout
|
|
34
|
+
// providers.md describes for terminal-started session import (`~/.omp/agent/sessions`).
|
|
35
|
+
const SESSION_DIR_NAME = "sessions";
|
|
36
|
+
const MCP_MANIFEST_FILENAME = "mcp.json";
|
|
37
|
+
const CONFIG_FILENAMES = ["config.yml", "config.yaml"] as const;
|
|
38
|
+
const WINDOWS_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
39
|
+
const WINDOWS_DEFAULT_SYSTEM_ROOT = "C:\\Windows";
|
|
40
|
+
|
|
41
|
+
// Only these variables ever reach a diagnostic probe's environment. Daemon credentials, API
|
|
42
|
+
// keys, and MCP headers — everything a real provider session legitimately inherits — are
|
|
43
|
+
// deliberately excluded; a `--version`/`--help` probe never needs them.
|
|
44
|
+
const PROBE_ENV_ALLOWLIST = [
|
|
45
|
+
"PATH",
|
|
46
|
+
"HOME",
|
|
47
|
+
"USERPROFILE",
|
|
48
|
+
"SystemRoot",
|
|
49
|
+
"TEMP",
|
|
50
|
+
"TMP",
|
|
51
|
+
"PATHEXT",
|
|
52
|
+
"LANG",
|
|
53
|
+
"LC_ALL",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export function buildProbeEnv(sourceEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
57
|
+
const env: NodeJS.ProcessEnv = {};
|
|
58
|
+
for (const key of PROBE_ENV_ALLOWLIST) {
|
|
59
|
+
const value = sourceEnv[key];
|
|
60
|
+
if (value !== undefined) env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
return env;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STATEFUL_CONFIG_ENV = [
|
|
66
|
+
"OMP_PROFILE",
|
|
67
|
+
"PI_PROFILE",
|
|
68
|
+
"PI_CODING_AGENT_DIR",
|
|
69
|
+
"PI_CONFIG_DIR",
|
|
70
|
+
"XDG_CACHE_HOME",
|
|
71
|
+
"XDG_CONFIG_HOME",
|
|
72
|
+
"XDG_DATA_HOME",
|
|
73
|
+
"XDG_RUNTIME_DIR",
|
|
74
|
+
"XDG_STATE_HOME",
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
export function buildStatefulCommandEnv(sourceEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
78
|
+
const env = buildProbeEnv(sourceEnv);
|
|
79
|
+
for (const key of STATEFUL_CONFIG_ENV) {
|
|
80
|
+
const value = sourceEnv[key];
|
|
81
|
+
if (value !== undefined) env[key] = value;
|
|
82
|
+
}
|
|
83
|
+
return env;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The minimal child-process shape a probe needs: readable stdio, exit reporting, and cleanup. */
|
|
87
|
+
export interface ProbeReadable {
|
|
88
|
+
on(event: "data", listener: (chunk: Buffer) => void): void;
|
|
89
|
+
removeAllListeners(): void;
|
|
90
|
+
destroy(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ProbeChildProcess {
|
|
94
|
+
readonly pid: number | undefined;
|
|
95
|
+
readonly stdout: ProbeReadable;
|
|
96
|
+
readonly stderr: ProbeReadable;
|
|
97
|
+
onError(listener: (error: NodeJS.ErrnoException) => void): void;
|
|
98
|
+
onClose(listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
99
|
+
removeAllListeners(): void;
|
|
100
|
+
/** Non-recursive kill for cleaning up helper processes such as taskkill itself. */
|
|
101
|
+
terminateDirect(signal: NodeJS.Signals): boolean;
|
|
102
|
+
/** Resolves true only after the complete provider process tree is confirmed terminated. */
|
|
103
|
+
terminateTree(graceMs: number): Promise<boolean>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type SpawnFn = (
|
|
107
|
+
command: string,
|
|
108
|
+
args: readonly string[],
|
|
109
|
+
env: NodeJS.ProcessEnv,
|
|
110
|
+
cwd?: string,
|
|
111
|
+
) => ProbeChildProcess;
|
|
112
|
+
|
|
113
|
+
export type SignalProcess = (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
114
|
+
|
|
115
|
+
export type DeadlineScheduler = (callback: () => void, delayMs: number) => () => void;
|
|
116
|
+
|
|
117
|
+
function scheduleDeadline(callback: () => void, delayMs: number): () => void {
|
|
118
|
+
const timer = setTimeout(callback, delayMs);
|
|
119
|
+
return () => clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function waitMs(ms: number): Promise<void> {
|
|
123
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
124
|
+
setTimeout(resolve, ms);
|
|
125
|
+
return promise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function processIsGone(error: unknown): boolean {
|
|
129
|
+
return (error as NodeJS.ErrnoException)?.code === "ESRCH";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* TERM→KILL escalation targets the detached POSIX process group, not just the leader. Both
|
|
134
|
+
* signals are attempted on schedule even if the leader closes after TERM, then signal 0 verifies
|
|
135
|
+
* the group is gone within the final bound.
|
|
136
|
+
*/
|
|
137
|
+
export async function terminatePosixProcessTree(
|
|
138
|
+
pid: number,
|
|
139
|
+
graceMs: number,
|
|
140
|
+
signalProcess: SignalProcess = process.kill,
|
|
141
|
+
wait: (ms: number) => Promise<void> = waitMs,
|
|
142
|
+
): Promise<boolean> {
|
|
143
|
+
try {
|
|
144
|
+
signalProcess(-pid, "SIGTERM");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!processIsGone(error)) return false;
|
|
147
|
+
}
|
|
148
|
+
await wait(graceMs);
|
|
149
|
+
try {
|
|
150
|
+
signalProcess(-pid, "SIGKILL");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!processIsGone(error)) return false;
|
|
153
|
+
}
|
|
154
|
+
await wait(graceMs);
|
|
155
|
+
try {
|
|
156
|
+
signalProcess(-pid, 0);
|
|
157
|
+
return false;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return processIsGone(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Terminates a Windows process tree via absolute System32/taskkill.exe. stdout/stderr are drained,
|
|
165
|
+
* error/close handlers are installed synchronously, and a timed-out taskkill is itself killed
|
|
166
|
+
* directly (never recursively) before a bounded final close wait. False propagates any failure.
|
|
167
|
+
*/
|
|
168
|
+
export async function killWindowsProcessTree(
|
|
169
|
+
pid: number,
|
|
170
|
+
spawnFn: SpawnFn,
|
|
171
|
+
systemRoot: string,
|
|
172
|
+
deadlineMs: number,
|
|
173
|
+
schedule: DeadlineScheduler = scheduleDeadline,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
const taskkillPath = join(systemRoot, "System32", "taskkill.exe");
|
|
176
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
177
|
+
let settled = false;
|
|
178
|
+
let timedOut = false;
|
|
179
|
+
let cancelDeadline = () => {};
|
|
180
|
+
let cancelFinalDeadline = () => {};
|
|
181
|
+
const finish = (success: boolean) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
cancelDeadline();
|
|
185
|
+
cancelFinalDeadline();
|
|
186
|
+
child.stdout.destroy();
|
|
187
|
+
child.stderr.destroy();
|
|
188
|
+
resolve(success);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
let child: ProbeChildProcess;
|
|
192
|
+
try {
|
|
193
|
+
child = spawnFn(
|
|
194
|
+
taskkillPath,
|
|
195
|
+
["/pid", String(pid), "/t", "/f"],
|
|
196
|
+
buildProbeEnv(currentOmpEnvironment()),
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
child.stdout.on("data", () => {});
|
|
202
|
+
child.stderr.on("data", () => {});
|
|
203
|
+
child.onError(() => finish(false));
|
|
204
|
+
child.onClose((code, signal) => finish(!timedOut && code === 0 && signal === null));
|
|
205
|
+
cancelDeadline = schedule(() => {
|
|
206
|
+
timedOut = true;
|
|
207
|
+
try {
|
|
208
|
+
child.terminateDirect("SIGKILL");
|
|
209
|
+
} catch {
|
|
210
|
+
finish(false);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
cancelFinalDeadline = schedule(() => finish(false), deadlineMs);
|
|
214
|
+
}, deadlineMs);
|
|
215
|
+
return promise;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function defaultSpawn(
|
|
219
|
+
command: string,
|
|
220
|
+
args: readonly string[],
|
|
221
|
+
env: NodeJS.ProcessEnv,
|
|
222
|
+
cwd?: string,
|
|
223
|
+
): ProbeChildProcess {
|
|
224
|
+
const child = spawn(command, args, {
|
|
225
|
+
cwd,
|
|
226
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
227
|
+
env,
|
|
228
|
+
detached: process.platform !== "win32",
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
pid: child.pid,
|
|
232
|
+
stdout: child.stdout,
|
|
233
|
+
stderr: child.stderr,
|
|
234
|
+
onError: (listener) => {
|
|
235
|
+
child.on("error", listener);
|
|
236
|
+
},
|
|
237
|
+
onClose: (listener) => {
|
|
238
|
+
child.on("close", listener);
|
|
239
|
+
},
|
|
240
|
+
removeAllListeners: () => {
|
|
241
|
+
child.removeAllListeners();
|
|
242
|
+
},
|
|
243
|
+
terminateDirect: (signal) => child.kill(signal),
|
|
244
|
+
terminateTree: async (graceMs) => {
|
|
245
|
+
if (child.pid === undefined) return true;
|
|
246
|
+
if (process.platform === "win32") {
|
|
247
|
+
return killWindowsProcessTree(
|
|
248
|
+
child.pid,
|
|
249
|
+
defaultSpawn,
|
|
250
|
+
currentOmpEnvironment().SystemRoot ?? WINDOWS_DEFAULT_SYSTEM_ROOT,
|
|
251
|
+
graceMs,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return terminatePosixProcessTree(child.pid, graceMs);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function isRegularExecutableFile(
|
|
259
|
+
candidate: string,
|
|
260
|
+
platform: NodeJS.Platform,
|
|
261
|
+
): Promise<boolean> {
|
|
262
|
+
let stats: Stats;
|
|
263
|
+
try {
|
|
264
|
+
stats = await stat(candidate);
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (!stats.isFile()) return false;
|
|
269
|
+
if (platform === "win32") return true; // The X_OK bit is not meaningful on Windows.
|
|
270
|
+
try {
|
|
271
|
+
await access(candidate, constants.X_OK);
|
|
272
|
+
return true;
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function windowsExecutableCandidates(base: string, pathExt: string): string[] {
|
|
279
|
+
if (/\.[^./\\]+$/.test(base)) return [base];
|
|
280
|
+
const extensions = pathExt
|
|
281
|
+
.split(";")
|
|
282
|
+
.map((ext) => ext.trim())
|
|
283
|
+
.filter(Boolean);
|
|
284
|
+
return extensions.length > 0 ? extensions.map((ext) => base + ext) : [base];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface ResolveExecutableOptions {
|
|
288
|
+
cwd: string;
|
|
289
|
+
platform: NodeJS.Platform;
|
|
290
|
+
pathExt: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolves the omp executable purely from the filesystem: a literal path (containing a
|
|
295
|
+
* separator) is checked directly; otherwise every `pathDirs` entry is probed in the given order,
|
|
296
|
+
* exactly mirroring how a real launch would find the same binary. No shell is ever invoked to do
|
|
297
|
+
* this lookup. An empty PATH entry conventionally means "current directory" in POSIX shells; that
|
|
298
|
+
* legacy behavior is deliberately not honored here so an attacker-writable daemon cwd can never
|
|
299
|
+
* shadow the real binary. A relative PATH entry resolves against the explicit `cwd`, never a
|
|
300
|
+
* process-global implicit cwd. The final match is realpath'd so a resolved symlink is reported
|
|
301
|
+
* consistently as its canonical target rather than the link path.
|
|
302
|
+
*/
|
|
303
|
+
export async function resolveExecutablePath(
|
|
304
|
+
command: string,
|
|
305
|
+
pathDirs: readonly string[],
|
|
306
|
+
options: ResolveExecutableOptions,
|
|
307
|
+
): Promise<string | null> {
|
|
308
|
+
const isWindows = options.platform === "win32";
|
|
309
|
+
const isLiteralPath = command.includes("/") || (isWindows && command.includes(sep));
|
|
310
|
+
const bases: string[] = [];
|
|
311
|
+
if (isLiteralPath) {
|
|
312
|
+
bases.push(isAbsolute(command) ? command : join(options.cwd, command));
|
|
313
|
+
} else {
|
|
314
|
+
for (const dir of pathDirs) {
|
|
315
|
+
if (dir.length === 0) continue;
|
|
316
|
+
bases.push(isAbsolute(dir) ? join(dir, command) : join(options.cwd, dir, command));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const base of bases) {
|
|
320
|
+
const candidates = isWindows ? windowsExecutableCandidates(base, options.pathExt) : [base];
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
if (!(await isRegularExecutableFile(candidate, options.platform))) continue;
|
|
323
|
+
try {
|
|
324
|
+
return await realpath(candidate);
|
|
325
|
+
} catch {
|
|
326
|
+
// Candidate disappeared or its link chain changed between stat and canonicalization.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export type BoundedRunOutcome = "exited" | "spawn-error" | "timeout";
|
|
334
|
+
|
|
335
|
+
export interface BoundedRun {
|
|
336
|
+
outcome: BoundedRunOutcome;
|
|
337
|
+
stdout: string;
|
|
338
|
+
truncated: boolean;
|
|
339
|
+
exitCode: number | null;
|
|
340
|
+
signal: NodeJS.Signals | null;
|
|
341
|
+
/** ENOENT vs everything else, so "not found" and "found but unrunnable" stay distinct. */
|
|
342
|
+
spawnErrorCode: string | null;
|
|
343
|
+
/** True when tree termination/verification failed or the leader missed its final close bound. */
|
|
344
|
+
cleanupFailed: boolean;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Runs one bounded, argv-only subprocess with a minimal allowlisted environment. stderr is
|
|
349
|
+
* drained and discarded, never inspected or forwarded. On timeout the full process tree is
|
|
350
|
+
* signaled (TERM, then KILL after `killGraceMs`, independent of whether the immediate leader has
|
|
351
|
+
* already closed) and the promise still waits, bounded by one more `killGraceMs`, for the close
|
|
352
|
+
* event so exit status is real whenever the process cooperates, and cleanup failure is reported
|
|
353
|
+
* when it does not.
|
|
354
|
+
*/
|
|
355
|
+
export function runBounded(
|
|
356
|
+
spawnFn: SpawnFn,
|
|
357
|
+
command: string,
|
|
358
|
+
args: readonly string[],
|
|
359
|
+
env: NodeJS.ProcessEnv,
|
|
360
|
+
timeoutMs: number,
|
|
361
|
+
killGraceMs: number,
|
|
362
|
+
maxBytes: number,
|
|
363
|
+
cwd?: string,
|
|
364
|
+
): Promise<BoundedRun> {
|
|
365
|
+
const { promise, resolve } = Promise.withResolvers<BoundedRun>();
|
|
366
|
+
let child: ProbeChildProcess;
|
|
367
|
+
try {
|
|
368
|
+
child = spawnFn(command, args, env, cwd);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
resolve({
|
|
371
|
+
outcome: "spawn-error",
|
|
372
|
+
stdout: "",
|
|
373
|
+
truncated: false,
|
|
374
|
+
exitCode: null,
|
|
375
|
+
signal: null,
|
|
376
|
+
spawnErrorCode: (error as NodeJS.ErrnoException)?.code ?? null,
|
|
377
|
+
cleanupFailed: false,
|
|
378
|
+
});
|
|
379
|
+
return promise;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let settled = false;
|
|
383
|
+
const stdoutChunks: Buffer[] = [];
|
|
384
|
+
let stdoutBytes = 0;
|
|
385
|
+
let truncated = false;
|
|
386
|
+
let timedOut = false;
|
|
387
|
+
let leaderClosed = false;
|
|
388
|
+
let leaderExitCode: number | null = null;
|
|
389
|
+
let leaderSignal: NodeJS.Signals | null = null;
|
|
390
|
+
let treeCleanupDone = false;
|
|
391
|
+
let treeCleanupSucceeded = false;
|
|
392
|
+
let finalLeaderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
393
|
+
const getStdout = () => Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8");
|
|
394
|
+
|
|
395
|
+
const cleanupListeners = () => {
|
|
396
|
+
child.stdout.removeAllListeners();
|
|
397
|
+
child.stderr.removeAllListeners();
|
|
398
|
+
child.removeAllListeners();
|
|
399
|
+
clearTimeout(deadlineTimer);
|
|
400
|
+
clearTimeout(finalLeaderTimer);
|
|
401
|
+
};
|
|
402
|
+
const finish = (result: BoundedRun) => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
cleanupListeners();
|
|
406
|
+
resolve(result);
|
|
407
|
+
};
|
|
408
|
+
const finishTimedOut = (cleanupFailed: boolean) => {
|
|
409
|
+
finish({
|
|
410
|
+
outcome: "timeout",
|
|
411
|
+
stdout: getStdout(),
|
|
412
|
+
truncated,
|
|
413
|
+
exitCode: leaderExitCode,
|
|
414
|
+
signal: leaderSignal,
|
|
415
|
+
spawnErrorCode: null,
|
|
416
|
+
cleanupFailed,
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
const settleAfterCleanup = () => {
|
|
420
|
+
if (!treeCleanupDone) return;
|
|
421
|
+
if (leaderClosed) {
|
|
422
|
+
finishTimedOut(!treeCleanupSucceeded);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
finalLeaderTimer = setTimeout(() => finishTimedOut(true), killGraceMs);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const deadlineTimer = setTimeout(() => {
|
|
429
|
+
timedOut = true;
|
|
430
|
+
void child
|
|
431
|
+
.terminateTree(killGraceMs)
|
|
432
|
+
.then((success) => {
|
|
433
|
+
treeCleanupSucceeded = success;
|
|
434
|
+
})
|
|
435
|
+
.catch(() => {
|
|
436
|
+
treeCleanupSucceeded = false;
|
|
437
|
+
})
|
|
438
|
+
.finally(() => {
|
|
439
|
+
treeCleanupDone = true;
|
|
440
|
+
settleAfterCleanup();
|
|
441
|
+
});
|
|
442
|
+
}, timeoutMs);
|
|
443
|
+
|
|
444
|
+
child.onError((error) => {
|
|
445
|
+
if (timedOut) {
|
|
446
|
+
leaderClosed = true;
|
|
447
|
+
settleAfterCleanup();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
finish({
|
|
451
|
+
outcome: "spawn-error",
|
|
452
|
+
stdout: getStdout(),
|
|
453
|
+
truncated,
|
|
454
|
+
exitCode: null,
|
|
455
|
+
signal: null,
|
|
456
|
+
spawnErrorCode: error.code ?? null,
|
|
457
|
+
cleanupFailed: false,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
461
|
+
const remaining = maxBytes - stdoutBytes;
|
|
462
|
+
if (remaining <= 0) {
|
|
463
|
+
truncated = true;
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (chunk.length > remaining) truncated = true;
|
|
467
|
+
const accepted = chunk.subarray(0, remaining);
|
|
468
|
+
stdoutChunks.push(accepted);
|
|
469
|
+
stdoutBytes += accepted.length;
|
|
470
|
+
});
|
|
471
|
+
child.stderr.on("data", () => {
|
|
472
|
+
// Intentionally discarded: never stored, parsed, or forwarded across the RPC boundary.
|
|
473
|
+
});
|
|
474
|
+
child.onClose((code, signal) => {
|
|
475
|
+
leaderClosed = true;
|
|
476
|
+
leaderExitCode = code;
|
|
477
|
+
leaderSignal = signal;
|
|
478
|
+
if (timedOut) {
|
|
479
|
+
settleAfterCleanup();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
finish({
|
|
483
|
+
outcome: "exited",
|
|
484
|
+
stdout: getStdout(),
|
|
485
|
+
truncated,
|
|
486
|
+
exitCode: code,
|
|
487
|
+
signal,
|
|
488
|
+
spawnErrorCode: null,
|
|
489
|
+
cleanupFailed: false,
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
return promise;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Anchored against the entire trimmed stdout (not merely one of several lines) so extra output,
|
|
497
|
+
// build metadata after a `+`, or a prefix/suffix can never be mistaken for the version. Digit
|
|
498
|
+
// groups are bounded to 4 characters and prerelease text to 32, so nothing unbounded parses out.
|
|
499
|
+
const VERSION_LINE_PATTERN =
|
|
500
|
+
/^omp\/(\d{1,4})\.(\d{1,4})\.(\d{1,4})(?:-([0-9A-Za-z][0-9A-Za-z.]{0,31}))?$/;
|
|
501
|
+
|
|
502
|
+
/** Requires the whole trimmed probe output to be exactly one canonical version line. */
|
|
503
|
+
function parseCanonicalVersionLine(stdout: string): OmpVersion | null {
|
|
504
|
+
const line = stdout.trim();
|
|
505
|
+
if (line.includes("\n") || line.includes("\r")) return null;
|
|
506
|
+
const match = VERSION_LINE_PATTERN.exec(line);
|
|
507
|
+
if (!match) return null;
|
|
508
|
+
const [, major, minor, patch, prerelease] = match;
|
|
509
|
+
return {
|
|
510
|
+
major: Number(major),
|
|
511
|
+
minor: Number(minor),
|
|
512
|
+
patch: Number(patch),
|
|
513
|
+
prerelease: prerelease ?? null,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function toVersionOutcome(result: BoundedRun): {
|
|
518
|
+
status: OmpVersionStatus;
|
|
519
|
+
version: OmpVersion | null;
|
|
520
|
+
} {
|
|
521
|
+
if (result.outcome === "timeout") return { status: "timeout", version: null };
|
|
522
|
+
if (result.outcome === "spawn-error") {
|
|
523
|
+
return {
|
|
524
|
+
status: result.spawnErrorCode === "ENOENT" ? "not-found" : "unrunnable",
|
|
525
|
+
version: null,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Only a clean, unsignaled, non-truncated exit can ever license "ok"; a nonzero exit or a
|
|
529
|
+
// delivered signal is a probe failure regardless of how plausible the stdout looks.
|
|
530
|
+
const exitedCleanly = result.exitCode === 0 && result.signal === null;
|
|
531
|
+
if (!exitedCleanly) return { status: "probe-failed", version: null };
|
|
532
|
+
if (result.truncated) return { status: "malformed", version: null };
|
|
533
|
+
const version = parseCanonicalVersionLine(result.stdout);
|
|
534
|
+
return version ? { status: "ok", version } : { status: "malformed", version: null };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Matches the exact flag line omp documents for `--mode`, not a bare "rpc-ui" substring that
|
|
538
|
+
// could appear anywhere in unrelated text.
|
|
539
|
+
const RPC_UI_HELP_PATTERN = /--mode=<value>\s+Output mode:.*\brpc-ui\b/i;
|
|
540
|
+
// Matches the exact "Available Tools" listing line for the built-in lsp tool.
|
|
541
|
+
const LSP_TOOL_PATTERN = /^\s*lsp\s+-\s+Language server protocol/im;
|
|
542
|
+
|
|
543
|
+
/** A clean, non-empty, non-truncated, zero-exit help dump is required before trusting either a
|
|
544
|
+
* positive or a negative match. */
|
|
545
|
+
function helpResultUsable(result: BoundedRun): boolean {
|
|
546
|
+
return (
|
|
547
|
+
result.outcome === "exited" &&
|
|
548
|
+
result.exitCode === 0 &&
|
|
549
|
+
result.signal === null &&
|
|
550
|
+
!result.truncated &&
|
|
551
|
+
result.stdout.trim().length > 0
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function detectRpcUiSupport(result: BoundedRun): boolean | null {
|
|
556
|
+
return helpResultUsable(result) ? RPC_UI_HELP_PATTERN.test(result.stdout) : null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function computeLspDiagnostics(result: BoundedRun): OmpLspDiagnostics {
|
|
560
|
+
if (!helpResultUsable(result)) return { status: "unknown" };
|
|
561
|
+
return { status: LSP_TOOL_PATTERN.test(result.stdout) ? "supported" : "not-advertised" };
|
|
562
|
+
}
|
|
563
|
+
export interface OmpAvailabilityProbeOptions {
|
|
564
|
+
command: readonly [string, ...string[]];
|
|
565
|
+
cwd: string;
|
|
566
|
+
environment?: NodeJS.ProcessEnv;
|
|
567
|
+
timeoutMs?: number;
|
|
568
|
+
platform?: NodeJS.Platform;
|
|
569
|
+
spawnFn?: SpawnFn;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function probeOmpAvailability(options: OmpAvailabilityProbeOptions): Promise<{
|
|
573
|
+
status: "missing" | "unrunnable" | "incompatible" | "available";
|
|
574
|
+
diagnostic?: string;
|
|
575
|
+
}> {
|
|
576
|
+
const environment = options.environment ?? currentOmpEnvironment();
|
|
577
|
+
const [command, ...prefixArgs] = options.command;
|
|
578
|
+
const resolvedPath = await resolveExecutablePath(
|
|
579
|
+
command,
|
|
580
|
+
(environment.PATH ?? "").split(delimiter),
|
|
581
|
+
{
|
|
582
|
+
cwd: options.cwd,
|
|
583
|
+
platform: options.platform ?? process.platform,
|
|
584
|
+
pathExt: environment.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
|
|
585
|
+
},
|
|
586
|
+
);
|
|
587
|
+
if (!resolvedPath) return { status: "missing", diagnostic: "OMP executable was not found" };
|
|
588
|
+
|
|
589
|
+
const operationTimeout = Math.max(1, Math.min(options.timeoutMs ?? VERSION_TIMEOUT_MS, 60_000));
|
|
590
|
+
const killGraceMs = Math.min(KILL_GRACE_MS, Math.max(1, Math.floor(operationTimeout / 4)));
|
|
591
|
+
const probeTimeoutMs = Math.max(1, operationTimeout - killGraceMs * 2);
|
|
592
|
+
const probeEnv = buildProbeEnv(environment);
|
|
593
|
+
const [versionRun, helpRun] = await Promise.all([
|
|
594
|
+
runBounded(
|
|
595
|
+
options.spawnFn ?? defaultSpawn,
|
|
596
|
+
resolvedPath,
|
|
597
|
+
[...prefixArgs, "--version"],
|
|
598
|
+
probeEnv,
|
|
599
|
+
probeTimeoutMs,
|
|
600
|
+
killGraceMs,
|
|
601
|
+
MAX_VERSION_BYTES,
|
|
602
|
+
options.cwd,
|
|
603
|
+
),
|
|
604
|
+
runBounded(
|
|
605
|
+
options.spawnFn ?? defaultSpawn,
|
|
606
|
+
resolvedPath,
|
|
607
|
+
[...prefixArgs, "--help"],
|
|
608
|
+
probeEnv,
|
|
609
|
+
probeTimeoutMs,
|
|
610
|
+
killGraceMs,
|
|
611
|
+
MAX_HELP_BYTES,
|
|
612
|
+
options.cwd,
|
|
613
|
+
),
|
|
614
|
+
]);
|
|
615
|
+
const version = toVersionOutcome(versionRun);
|
|
616
|
+
if (versionRun.cleanupFailed || helpRun.cleanupFailed) {
|
|
617
|
+
return { status: "unrunnable", diagnostic: "OMP availability probe cleanup failed" };
|
|
618
|
+
}
|
|
619
|
+
if (version.status === "not-found") {
|
|
620
|
+
return { status: "missing", diagnostic: "OMP executable was not found" };
|
|
621
|
+
}
|
|
622
|
+
if (version.status === "malformed") {
|
|
623
|
+
return { status: "incompatible", diagnostic: "OMP returned an unrecognized version" };
|
|
624
|
+
}
|
|
625
|
+
if (version.status !== "ok") {
|
|
626
|
+
return { status: "unrunnable", diagnostic: `OMP version probe ${version.status}` };
|
|
627
|
+
}
|
|
628
|
+
const rpcUiSupported = detectRpcUiSupport(helpRun);
|
|
629
|
+
if (rpcUiSupported === false) {
|
|
630
|
+
return { status: "incompatible", diagnostic: "OMP does not advertise rpc-ui support" };
|
|
631
|
+
}
|
|
632
|
+
if (rpcUiSupported === null) {
|
|
633
|
+
return { status: "unrunnable", diagnostic: "OMP rpc-ui compatibility probe failed" };
|
|
634
|
+
}
|
|
635
|
+
return { status: "available" };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isEnoent(error: unknown): boolean {
|
|
639
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
640
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function isPermissionError(error: unknown): boolean {
|
|
644
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
645
|
+
return code === "EACCES" || code === "EPERM";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Classifies a known diagnostic path without following symlinks. A symlink is "wrong-type"
|
|
650
|
+
* rather than an invitation to read an arbitrary target outside omp's expected state tree.
|
|
651
|
+
* Directories additionally require both read and traverse (X_OK) access before "available";
|
|
652
|
+
* a directory that exists but cannot be listed is not usable state, so it is "invalid".
|
|
653
|
+
*/
|
|
654
|
+
async function classifyPath(path: string, kind: "file" | "directory"): Promise<PathState> {
|
|
655
|
+
let pathStats: Stats;
|
|
656
|
+
try {
|
|
657
|
+
pathStats = await lstat(path);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
return isEnoent(error) ? "missing" : "unreadable";
|
|
660
|
+
}
|
|
661
|
+
if (pathStats.isSymbolicLink()) return "wrong-type";
|
|
662
|
+
const matchesKind = kind === "file" ? pathStats.isFile() : pathStats.isDirectory();
|
|
663
|
+
if (!matchesKind) return "wrong-type";
|
|
664
|
+
const mode = kind === "file" ? constants.R_OK : constants.R_OK | constants.X_OK;
|
|
665
|
+
try {
|
|
666
|
+
await access(path, mode);
|
|
667
|
+
} catch {
|
|
668
|
+
return "unreadable";
|
|
669
|
+
}
|
|
670
|
+
return "available";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
type BoundedFileRead =
|
|
674
|
+
| { state: "available"; text: string }
|
|
675
|
+
| { state: "missing" | "unreadable" | "wrong-type" | "invalid" };
|
|
676
|
+
/** Bounded, no-symlink file read shared by the config and MCP-manifest readers. */
|
|
677
|
+
async function readBoundedNoSymlinkFile(path: string, maxBytes: number): Promise<BoundedFileRead> {
|
|
678
|
+
let pathStats: Stats;
|
|
679
|
+
try {
|
|
680
|
+
pathStats = await lstat(path);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { state: isEnoent(error) ? "missing" : "unreadable" };
|
|
683
|
+
}
|
|
684
|
+
if (pathStats.isSymbolicLink() || !pathStats.isFile()) return { state: "wrong-type" };
|
|
685
|
+
|
|
686
|
+
let handle: FileHandle | undefined;
|
|
687
|
+
try {
|
|
688
|
+
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
689
|
+
handle = await open(path, constants.O_RDONLY | noFollowFlag);
|
|
690
|
+
const openedStats = await handle.stat();
|
|
691
|
+
if (!openedStats.isFile()) return { state: "wrong-type" };
|
|
692
|
+
if (openedStats.size > maxBytes) return { state: "invalid" };
|
|
693
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
694
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
695
|
+
if (bytesRead > maxBytes) return { state: "invalid" };
|
|
696
|
+
return { state: "available", text: buffer.subarray(0, bytesRead).toString("utf8") };
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return { state: isPermissionError(error) ? "unreadable" : "invalid" };
|
|
699
|
+
} finally {
|
|
700
|
+
await handle?.close().catch(() => undefined);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
interface SafeConfigResult {
|
|
705
|
+
path: string;
|
|
706
|
+
state: PathState;
|
|
707
|
+
config: OmpConfig | null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Reads omp's config.yml/.yaml with the same safe-allowlist schema the config surface uses, but
|
|
712
|
+
* classifies anything that parses to a non-mapping root or fails the allowed-section schema
|
|
713
|
+
* (including an invalid `memory.backend`) as "invalid" rather than silently degrading to an
|
|
714
|
+
* empty-but-"available" config — a health check must not call a broken config healthy.
|
|
715
|
+
*/
|
|
716
|
+
async function readSafeConfig(agentDir: string): Promise<SafeConfigResult> {
|
|
717
|
+
const canonicalPath = join(agentDir, CONFIG_FILENAMES[0]);
|
|
718
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
719
|
+
const path = join(agentDir, filename);
|
|
720
|
+
const fileRead = await readBoundedNoSymlinkFile(path, MAX_CONFIG_BYTES);
|
|
721
|
+
if (fileRead.state === "missing") continue;
|
|
722
|
+
if (fileRead.state !== "available" || !("text" in fileRead)) {
|
|
723
|
+
return { path, state: fileRead.state, config: null };
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
const raw: unknown = parseYaml(fileRead.text);
|
|
727
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
728
|
+
return { path, state: "invalid", config: null };
|
|
729
|
+
}
|
|
730
|
+
const parsed = OmpConfigSchema.safeParse(raw);
|
|
731
|
+
if (!parsed.success) return { path, state: "invalid", config: null };
|
|
732
|
+
return { path, state: "available", config: parsed.data };
|
|
733
|
+
} catch {
|
|
734
|
+
return { path, state: "invalid", config: null };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return { path: canonicalPath, state: "missing", config: null };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const McpManifestSchema = z
|
|
741
|
+
.object({ mcpServers: z.record(z.string(), z.unknown()).optional() })
|
|
742
|
+
.passthrough();
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Reports only a bounded count from omp's mcp.json manifest. Server identifiers and every nested
|
|
746
|
+
* command/env/header/URL remain server-side and never cross the RPC boundary.
|
|
747
|
+
*/
|
|
748
|
+
async function computeMcpDiagnostics(agentDir: string): Promise<OmpMcpDiagnostics> {
|
|
749
|
+
const fileRead = await readBoundedNoSymlinkFile(
|
|
750
|
+
join(agentDir, MCP_MANIFEST_FILENAME),
|
|
751
|
+
MAX_MCP_MANIFEST_BYTES,
|
|
752
|
+
);
|
|
753
|
+
if (fileRead.state === "missing") {
|
|
754
|
+
return {
|
|
755
|
+
status: "unavailable",
|
|
756
|
+
serverCount: null,
|
|
757
|
+
reason: `No ${MCP_MANIFEST_FILENAME} manifest found under the agent root`,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (fileRead.state === "wrong-type") {
|
|
761
|
+
return {
|
|
762
|
+
status: "wrong-type",
|
|
763
|
+
serverCount: null,
|
|
764
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root is not a regular file`,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (fileRead.state === "unreadable") {
|
|
768
|
+
return {
|
|
769
|
+
status: "unreadable",
|
|
770
|
+
serverCount: null,
|
|
771
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root could not be read`,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (fileRead.state === "invalid") {
|
|
775
|
+
return {
|
|
776
|
+
status: "invalid",
|
|
777
|
+
serverCount: null,
|
|
778
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root is too large or unstable`,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
if (!("text" in fileRead)) {
|
|
782
|
+
return {
|
|
783
|
+
status: "invalid",
|
|
784
|
+
serverCount: null,
|
|
785
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root could not be read`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
let raw: unknown;
|
|
790
|
+
try {
|
|
791
|
+
raw = JSON.parse(fileRead.text);
|
|
792
|
+
} catch {
|
|
793
|
+
return {
|
|
794
|
+
status: "invalid",
|
|
795
|
+
serverCount: null,
|
|
796
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root is not valid JSON`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const parsed = McpManifestSchema.safeParse(raw);
|
|
800
|
+
if (!parsed.success) {
|
|
801
|
+
return {
|
|
802
|
+
status: "invalid",
|
|
803
|
+
serverCount: null,
|
|
804
|
+
reason: `${MCP_MANIFEST_FILENAME} under the agent root does not match the expected shape`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
status: "configured",
|
|
809
|
+
serverCount: Object.keys(parsed.data.mcpServers ?? {}).length,
|
|
810
|
+
reason: null,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export interface ProcessDiagnosticsFs {
|
|
815
|
+
readdir(path: string): Promise<string[]>;
|
|
816
|
+
lstat(path: string): Promise<Stats>;
|
|
817
|
+
readMetadata(path: string, maxBytes: number): Promise<BoundedFileRead>;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const processDiagnosticsFs: ProcessDiagnosticsFs = {
|
|
821
|
+
readdir,
|
|
822
|
+
lstat,
|
|
823
|
+
readMetadata: readBoundedNoSymlinkFile,
|
|
824
|
+
};
|
|
825
|
+
const MAX_PROCESS_METADATA_BYTES = 64 * 1024;
|
|
826
|
+
const MAX_PROCESS_METADATA_ENTRIES = 4_096;
|
|
827
|
+
const ProcessStateSchema = z.object({
|
|
828
|
+
daemon: z.object({
|
|
829
|
+
state: z.string().max(32),
|
|
830
|
+
exitedAt: z.number().finite().nonnegative().nullish(),
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
const ACTIVE_PROCESS_STATES = new Set(["starting", "running", "ready", "restarting"]);
|
|
834
|
+
const HISTORICAL_PROCESS_STATES = new Set(["exited", "failed", "stopped"]);
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Classifies bounded, regular, non-symlink Hub metadata. A recorded active state is not a
|
|
838
|
+
* liveness check. Historical files remain useful evidence and are never counted as running.
|
|
839
|
+
* Only aggregate counts leave this reader; metadata arguments, paths and values are discarded.
|
|
840
|
+
*/
|
|
841
|
+
export async function computeProcessDiagnostics(
|
|
842
|
+
hubRunRoot: string,
|
|
843
|
+
fs: ProcessDiagnosticsFs = processDiagnosticsFs,
|
|
844
|
+
): Promise<OmpProcessDiagnostics> {
|
|
845
|
+
const unavailableCounts = {
|
|
846
|
+
trackedCount: null,
|
|
847
|
+
activeCount: null,
|
|
848
|
+
historicalCount: null,
|
|
849
|
+
unknownCount: null,
|
|
850
|
+
};
|
|
851
|
+
let projectHashes: string[];
|
|
852
|
+
try {
|
|
853
|
+
const rootStats = await fs.lstat(hubRunRoot);
|
|
854
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
855
|
+
return { status: "unavailable", ...unavailableCounts };
|
|
856
|
+
}
|
|
857
|
+
projectHashes = await fs.readdir(hubRunRoot);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
return { status: isEnoent(error) ? "unavailable" : "unknown", ...unavailableCounts };
|
|
860
|
+
}
|
|
861
|
+
let trackedCount = 0;
|
|
862
|
+
let activeCount = 0;
|
|
863
|
+
let historicalCount = 0;
|
|
864
|
+
let unknownCount = 0;
|
|
865
|
+
let partial = projectHashes.length > MAX_PROCESS_METADATA_ENTRIES;
|
|
866
|
+
let visitedEntries = 0;
|
|
867
|
+
for (const hash of projectHashes.slice(0, MAX_PROCESS_METADATA_ENTRIES)) {
|
|
868
|
+
const projectDir = join(hubRunRoot, hash);
|
|
869
|
+
const daemonsDir = join(projectDir, "daemons");
|
|
870
|
+
try {
|
|
871
|
+
const projectStats = await fs.lstat(projectDir);
|
|
872
|
+
if (projectStats.isSymbolicLink() || !projectStats.isDirectory()) continue;
|
|
873
|
+
const daemonStats = await fs.lstat(daemonsDir);
|
|
874
|
+
if (daemonStats.isSymbolicLink() || !daemonStats.isDirectory()) continue;
|
|
875
|
+
} catch (error) {
|
|
876
|
+
if (!isEnoent(error)) partial = true;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let daemonNames: string[];
|
|
881
|
+
try {
|
|
882
|
+
daemonNames = await fs.readdir(daemonsDir);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
if (!isEnoent(error)) partial = true;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
for (const name of daemonNames) {
|
|
888
|
+
if (++visitedEntries > MAX_PROCESS_METADATA_ENTRIES) {
|
|
889
|
+
partial = true;
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const daemonDir = join(daemonsDir, name);
|
|
894
|
+
const directoryStats = await fs.lstat(daemonDir);
|
|
895
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) continue;
|
|
896
|
+
const metaPath = join(daemonDir, "meta.json");
|
|
897
|
+
const metaStats = await fs.lstat(metaPath);
|
|
898
|
+
if (metaStats.isSymbolicLink() || !metaStats.isFile()) continue;
|
|
899
|
+
trackedCount += 1;
|
|
900
|
+
let category: "active" | "historical" | "unknown" = "unknown";
|
|
901
|
+
try {
|
|
902
|
+
const file = await fs.readMetadata(metaPath, MAX_PROCESS_METADATA_BYTES);
|
|
903
|
+
if (file.state === "available") {
|
|
904
|
+
const parsed = ProcessStateSchema.safeParse(JSON.parse(file.text));
|
|
905
|
+
if (parsed.success) {
|
|
906
|
+
const { state, exitedAt } = parsed.data.daemon;
|
|
907
|
+
if (HISTORICAL_PROCESS_STATES.has(state) || exitedAt != null) category = "historical";
|
|
908
|
+
else if (ACTIVE_PROCESS_STATES.has(state)) category = "active";
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
// Read/access/parse errors have an honest unknown bucket, never a guessed active state.
|
|
913
|
+
}
|
|
914
|
+
if (category === "historical") historicalCount += 1;
|
|
915
|
+
else if (category === "active") activeCount += 1;
|
|
916
|
+
else {
|
|
917
|
+
unknownCount += 1;
|
|
918
|
+
partial = true;
|
|
919
|
+
}
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (!isEnoent(error)) partial = true;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (visitedEntries > MAX_PROCESS_METADATA_ENTRIES) break;
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
status: partial ? "partial" : "ok",
|
|
928
|
+
trackedCount,
|
|
929
|
+
activeCount,
|
|
930
|
+
historicalCount,
|
|
931
|
+
unknownCount,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function homeRelative(path: string, homeDir: string): string | null {
|
|
936
|
+
if (path === homeDir) return "~";
|
|
937
|
+
const suffix = relative(homeDir, path);
|
|
938
|
+
if (suffix === "" || suffix === ".." || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
return `~/${suffix.split(sep).join("/")}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** External locations always collapse to the same constant; no basename or raw env value leaks. */
|
|
945
|
+
function sanitizeRootPath(path: string, homeDir: string): string {
|
|
946
|
+
return homeRelative(path, homeDir) ?? "<custom path>";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function sanitizeDerivedPath(
|
|
950
|
+
rawRoot: string,
|
|
951
|
+
sanitizedRoot: string,
|
|
952
|
+
fullPath: string,
|
|
953
|
+
fallbackHomeDir?: string,
|
|
954
|
+
): string {
|
|
955
|
+
const suffix = relative(rawRoot, fullPath);
|
|
956
|
+
if (suffix === "" || suffix === ".." || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) {
|
|
957
|
+
return fallbackHomeDir ? sanitizeRootPath(fullPath, fallbackHomeDir) : sanitizedRoot;
|
|
958
|
+
}
|
|
959
|
+
return `${sanitizedRoot}/${suffix.split(sep).join("/")}`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export interface ProviderDiagnosticsDeps {
|
|
963
|
+
agentDir: string;
|
|
964
|
+
dataDir?: string;
|
|
965
|
+
sessionDir?: string;
|
|
966
|
+
command: string;
|
|
967
|
+
pathDirs: readonly string[];
|
|
968
|
+
cwd: string;
|
|
969
|
+
platform: NodeJS.Platform;
|
|
970
|
+
pathExt: string;
|
|
971
|
+
env: NodeJS.ProcessEnv;
|
|
972
|
+
spawnFn: SpawnFn;
|
|
973
|
+
hubRunRoot: string;
|
|
974
|
+
homeDir: string;
|
|
975
|
+
versionTimeoutMs?: number;
|
|
976
|
+
helpTimeoutMs?: number;
|
|
977
|
+
killGraceMs?: number;
|
|
978
|
+
maxVersionBytes?: number;
|
|
979
|
+
maxHelpBytes?: number;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export async function computeOmpProviderHealth(
|
|
983
|
+
deps: ProviderDiagnosticsDeps,
|
|
984
|
+
): Promise<OmpProviderHealth> {
|
|
985
|
+
const agentDir = resolve(deps.agentDir);
|
|
986
|
+
const dataDir = resolve(deps.dataDir ?? agentDir);
|
|
987
|
+
const sessionRoot = resolve(deps.sessionDir ?? join(dataDir, SESSION_DIR_NAME));
|
|
988
|
+
const homeDir = resolve(deps.homeDir);
|
|
989
|
+
const versionTimeoutMs = deps.versionTimeoutMs ?? VERSION_TIMEOUT_MS;
|
|
990
|
+
const helpTimeoutMs = deps.helpTimeoutMs ?? HELP_TIMEOUT_MS;
|
|
991
|
+
const killGraceMs = deps.killGraceMs ?? KILL_GRACE_MS;
|
|
992
|
+
const maxVersionBytes = deps.maxVersionBytes ?? MAX_VERSION_BYTES;
|
|
993
|
+
const maxHelpBytes = deps.maxHelpBytes ?? MAX_HELP_BYTES;
|
|
994
|
+
const probeEnv = buildProbeEnv(deps.env);
|
|
995
|
+
|
|
996
|
+
const resolvedPath = await resolveExecutablePath(deps.command, deps.pathDirs, {
|
|
997
|
+
cwd: deps.cwd,
|
|
998
|
+
platform: deps.platform,
|
|
999
|
+
pathExt: deps.pathExt,
|
|
1000
|
+
});
|
|
1001
|
+
const installed = resolvedPath !== null;
|
|
1002
|
+
|
|
1003
|
+
const [versionRun, helpRun] = await Promise.all([
|
|
1004
|
+
installed
|
|
1005
|
+
? runBounded(
|
|
1006
|
+
deps.spawnFn,
|
|
1007
|
+
resolvedPath,
|
|
1008
|
+
["--version"],
|
|
1009
|
+
probeEnv,
|
|
1010
|
+
versionTimeoutMs,
|
|
1011
|
+
killGraceMs,
|
|
1012
|
+
maxVersionBytes,
|
|
1013
|
+
deps.cwd,
|
|
1014
|
+
)
|
|
1015
|
+
: null,
|
|
1016
|
+
installed
|
|
1017
|
+
? runBounded(
|
|
1018
|
+
deps.spawnFn,
|
|
1019
|
+
resolvedPath,
|
|
1020
|
+
["--help"],
|
|
1021
|
+
probeEnv,
|
|
1022
|
+
helpTimeoutMs,
|
|
1023
|
+
killGraceMs,
|
|
1024
|
+
maxHelpBytes,
|
|
1025
|
+
deps.cwd,
|
|
1026
|
+
)
|
|
1027
|
+
: null,
|
|
1028
|
+
]);
|
|
1029
|
+
|
|
1030
|
+
const versionOutcome = versionRun
|
|
1031
|
+
? toVersionOutcome(versionRun)
|
|
1032
|
+
: { status: "not-found" as const, version: null };
|
|
1033
|
+
const rpcUiSupported = helpRun ? detectRpcUiSupport(helpRun) : null;
|
|
1034
|
+
const lsp = helpRun ? computeLspDiagnostics(helpRun) : { status: "unknown" as const };
|
|
1035
|
+
const processCleanupFailed = Boolean(versionRun?.cleanupFailed || helpRun?.cleanupFailed);
|
|
1036
|
+
|
|
1037
|
+
const agentDbPath = join(dataDir, AGENT_DB_FILENAME);
|
|
1038
|
+
const historyDbPath = join(dataDir, HISTORY_DB_FILENAME);
|
|
1039
|
+
const [
|
|
1040
|
+
configResult,
|
|
1041
|
+
agentRootState,
|
|
1042
|
+
sessionRootState,
|
|
1043
|
+
agentDbState,
|
|
1044
|
+
historyDbState,
|
|
1045
|
+
mcp,
|
|
1046
|
+
processDiagnostics,
|
|
1047
|
+
] = await Promise.all([
|
|
1048
|
+
readSafeConfig(agentDir),
|
|
1049
|
+
classifyPath(agentDir, "directory"),
|
|
1050
|
+
classifyPath(sessionRoot, "directory"),
|
|
1051
|
+
classifyPath(agentDbPath, "file"),
|
|
1052
|
+
classifyPath(historyDbPath, "file"),
|
|
1053
|
+
computeMcpDiagnostics(agentDir),
|
|
1054
|
+
computeProcessDiagnostics(resolve(deps.hubRunRoot)),
|
|
1055
|
+
]);
|
|
1056
|
+
const configState = configResult.state;
|
|
1057
|
+
const sanitizedAgentRoot = sanitizeRootPath(agentDir, homeDir);
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
binary: {
|
|
1061
|
+
installed,
|
|
1062
|
+
resolvedPath: resolvedPath ? sanitizeRootPath(resolve(resolvedPath), homeDir) : null,
|
|
1063
|
+
version: versionOutcome.version,
|
|
1064
|
+
versionStatus: versionOutcome.status,
|
|
1065
|
+
processCleanupFailed,
|
|
1066
|
+
},
|
|
1067
|
+
rpcUi: {
|
|
1068
|
+
checked: installed,
|
|
1069
|
+
supported: rpcUiSupported,
|
|
1070
|
+
},
|
|
1071
|
+
lsp,
|
|
1072
|
+
mcp,
|
|
1073
|
+
process: processDiagnostics,
|
|
1074
|
+
roots: {
|
|
1075
|
+
agentRoot: sanitizedAgentRoot,
|
|
1076
|
+
agentRootState,
|
|
1077
|
+
configPath: sanitizeDerivedPath(agentDir, sanitizedAgentRoot, configResult.path),
|
|
1078
|
+
configState,
|
|
1079
|
+
sessionRoot: sanitizeDerivedPath(agentDir, sanitizedAgentRoot, sessionRoot, homeDir),
|
|
1080
|
+
sessionRootState,
|
|
1081
|
+
},
|
|
1082
|
+
databases: {
|
|
1083
|
+
agentDbState,
|
|
1084
|
+
historyDbState,
|
|
1085
|
+
},
|
|
1086
|
+
memoryBackend:
|
|
1087
|
+
configState === "available" ? (configResult.config?.memory?.backend ?? null) : null,
|
|
1088
|
+
checkedAt: new Date().toISOString(),
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const cachedHealth = new Map<string, { value: OmpProviderHealth; expiresAt: number }>();
|
|
1093
|
+
const inFlightHealth = new Map<string, Promise<OmpProviderHealth>>();
|
|
1094
|
+
|
|
1095
|
+
function defaultHubRunRoot(environment: NodeJS.ProcessEnv = currentOmpEnvironment()): string {
|
|
1096
|
+
return environment.PASEO_OMP_RUN_DIR ?? join(homedir(), ".omp", "run", "daemons");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Single-flights and short-TTL-caches the health computation so several connected clients
|
|
1101
|
+
* polling or refreshing around the same time never each launch their own `--version`/`--help`
|
|
1102
|
+
* subprocess pair; they share one in-flight probe or its just-completed result. `force` skips a
|
|
1103
|
+
* still-fresh cached value but still joins an in-flight probe rather than starting a duplicate.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function resolveGetOmpProviderHealth(
|
|
1106
|
+
input: RpcInput<typeof getOmpProviderHealth>,
|
|
1107
|
+
): Promise<OmpProviderHealth> {
|
|
1108
|
+
const cwd = input.cwd ?? process.cwd();
|
|
1109
|
+
const environment = currentOmpEnvironment();
|
|
1110
|
+
const agentDir = ompAgentDir(environment);
|
|
1111
|
+
const dataDir = ompDataDir(environment);
|
|
1112
|
+
const sessionDir = ompSessionDir(environment);
|
|
1113
|
+
const now = Date.now();
|
|
1114
|
+
const key = JSON.stringify([cwd, agentDir, dataDir, sessionDir]);
|
|
1115
|
+
const cached = cachedHealth.get(key);
|
|
1116
|
+
if (!input.force && cached && cached.expiresAt > now) return cached.value;
|
|
1117
|
+
const inFlight = inFlightHealth.get(key);
|
|
1118
|
+
if (inFlight) return inFlight;
|
|
1119
|
+
|
|
1120
|
+
const computation = computeOmpProviderHealth({
|
|
1121
|
+
agentDir,
|
|
1122
|
+
dataDir,
|
|
1123
|
+
sessionDir,
|
|
1124
|
+
command: environment.OMP_COMMAND ?? "omp",
|
|
1125
|
+
pathDirs: (environment.PATH ?? "").split(delimiter),
|
|
1126
|
+
cwd,
|
|
1127
|
+
platform: process.platform,
|
|
1128
|
+
pathExt: environment.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
|
|
1129
|
+
env: environment,
|
|
1130
|
+
spawnFn: defaultSpawn,
|
|
1131
|
+
hubRunRoot: defaultHubRunRoot(environment),
|
|
1132
|
+
homeDir: homedir(),
|
|
1133
|
+
})
|
|
1134
|
+
.then((value) => {
|
|
1135
|
+
cachedHealth.set(key, { value, expiresAt: Date.now() + HEALTH_CACHE_TTL_MS });
|
|
1136
|
+
return value;
|
|
1137
|
+
})
|
|
1138
|
+
.finally(() => {
|
|
1139
|
+
inFlightHealth.delete(key);
|
|
1140
|
+
});
|
|
1141
|
+
inFlightHealth.set(key, computation);
|
|
1142
|
+
return computation;
|
|
1143
|
+
}
|