@cueai/omni-reader-mcp 1.0.2 → 1.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/README.md +115 -26
- package/dist/artifact-store.d.ts +11 -0
- package/dist/artifact-store.js +94 -48
- package/dist/cli/agent-config.d.ts +29 -4
- package/dist/cli/agent-config.js +910 -107
- package/dist/cli/arguments.d.ts +32 -0
- package/dist/cli/arguments.js +120 -0
- package/dist/cli/doctor.d.ts +42 -1
- package/dist/cli/doctor.js +109 -36
- package/dist/cli/setup.d.ts +3 -0
- package/dist/cli/setup.js +103 -18
- package/dist/cli/uninstall.d.ts +6 -0
- package/dist/cli/uninstall.js +37 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/cube-client.d.ts +5 -3
- package/dist/cube-client.js +16 -11
- package/dist/cursor.js +2 -0
- package/dist/errors.d.ts +32 -1
- package/dist/errors.js +26 -1
- package/dist/iiis-client.d.ts +18 -4
- package/dist/iiis-client.js +194 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +93 -32
- package/dist/multipart-body.js +2 -0
- package/dist/onboarding-policy.d.ts +10 -0
- package/dist/onboarding-policy.js +58 -0
- package/dist/operation-journal.d.ts +50 -1
- package/dist/operation-journal.js +473 -114
- package/dist/operation-manager.d.ts +75 -0
- package/dist/operation-manager.js +1324 -0
- package/dist/path-security.d.ts +1 -0
- package/dist/path-security.js +26 -6
- package/dist/progress.d.ts +6 -1
- package/dist/protocol.d.ts +26 -13
- package/dist/protocol.js +34 -10
- package/dist/remote-client.d.ts +17 -0
- package/dist/remote-client.js +233 -0
- package/dist/result-contract.d.ts +199 -0
- package/dist/result-contract.js +235 -0
- package/dist/server.js +21 -4
- package/dist/source.d.ts +8 -0
- package/dist/source.js +37 -0
- package/dist/task-runtime.d.ts +13 -0
- package/dist/task-runtime.js +94 -0
- package/dist/tools.d.ts +19 -1
- package/dist/tools.js +317 -112
- package/package.json +3 -3
package/dist/cli/agent-config.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
|
-
import { chmod, lstat, mkdir, open, rename, } from "node:fs/promises";
|
|
3
|
+
import { chmod, lstat, mkdir, open, realpath, rename, unlink, } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { BRIDGE_RELEASE_VERSION, REMOTE_OMNI_MCP_URL, } from "../constants.js";
|
|
6
|
+
const PACKAGE_SPEC = `@cueai/omni-reader-mcp@${BRIDGE_RELEASE_VERSION}`;
|
|
7
|
+
const LEGACY_PACKAGE_SPEC = "@cueai/omni-reader-mcp";
|
|
5
8
|
function isRecord(value) {
|
|
6
9
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
10
|
}
|
|
@@ -13,17 +16,70 @@ function fileError(message) {
|
|
|
13
16
|
function pathModule(platform) {
|
|
14
17
|
return platform === "win32" ? path.win32 : path.posix;
|
|
15
18
|
}
|
|
19
|
+
function containsPath(parent, child, paths) {
|
|
20
|
+
const relative = paths.relative(parent, child);
|
|
21
|
+
return relative === "" || (relative !== ".." &&
|
|
22
|
+
!relative.startsWith(`..${paths.sep}`) &&
|
|
23
|
+
!paths.isAbsolute(relative));
|
|
24
|
+
}
|
|
25
|
+
function normalizedOmniName(value) {
|
|
26
|
+
return value.toLowerCase().replace(/[^a-z0-9]/gu, "") === "omnireader";
|
|
27
|
+
}
|
|
28
|
+
function redactSecrets(value) {
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return value.map(redactSecrets);
|
|
31
|
+
if (!isRecord(value))
|
|
32
|
+
return value;
|
|
33
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
34
|
+
key,
|
|
35
|
+
/authorization|api[_-]?key|token|secret|password/iu.test(key)
|
|
36
|
+
? "[redacted]"
|
|
37
|
+
: redactSecrets(child),
|
|
38
|
+
]));
|
|
39
|
+
}
|
|
40
|
+
function safeCredentialReferences(value) {
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
return true;
|
|
43
|
+
for (const [key, child] of Object.entries(value)) {
|
|
44
|
+
if (/authorization|api[_-]?key|token|secret|password/iu.test(key)) {
|
|
45
|
+
if (child !== "${CUE_API_KEY}" &&
|
|
46
|
+
child !== "Bearer ${CUE_API_KEY}")
|
|
47
|
+
return false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(child)) {
|
|
51
|
+
if (!child.every((item) => !isRecord(item) || safeCredentialReferences(item)))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
else if (isRecord(child) && !safeCredentialReferences(child)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
function assertNoLiteralApiKey(serialized, environment) {
|
|
61
|
+
const apiKey = environment.env?.CUE_API_KEY;
|
|
62
|
+
if (apiKey !== undefined && apiKey.length > 0 && serialized.includes(apiKey)) {
|
|
63
|
+
throw fileError("The Agent configuration contains a literal API key; no changes were written.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
16
66
|
export function parseAgentTarget(value) {
|
|
17
67
|
const normalized = value.trim().toLowerCase();
|
|
68
|
+
if (normalized === "hermes")
|
|
69
|
+
return "hermes";
|
|
18
70
|
if (normalized === "cursor")
|
|
19
71
|
return "cursor";
|
|
20
|
-
if (normalized === "claude desktop" ||
|
|
72
|
+
if (normalized === "claude desktop" ||
|
|
73
|
+
normalized === "claude" ||
|
|
74
|
+
normalized === "claude-desktop")
|
|
21
75
|
return "claude-desktop";
|
|
22
|
-
}
|
|
23
76
|
return "generic";
|
|
24
77
|
}
|
|
25
78
|
export function agentConfigPath(target, environment) {
|
|
26
79
|
const paths = pathModule(environment.platform);
|
|
80
|
+
if (target === "hermes") {
|
|
81
|
+
return paths.join(environment.homeDirectory, ".hermes", "config.yaml");
|
|
82
|
+
}
|
|
27
83
|
if (target === "cursor") {
|
|
28
84
|
return paths.join(environment.homeDirectory, ".cursor", "mcp.json");
|
|
29
85
|
}
|
|
@@ -40,31 +96,73 @@ export function agentConfigPath(target, environment) {
|
|
|
40
96
|
}
|
|
41
97
|
return undefined;
|
|
42
98
|
}
|
|
99
|
+
export function agentBackupPath(configPath) {
|
|
100
|
+
return `${configPath}.omni-reader.backup.json`;
|
|
101
|
+
}
|
|
43
102
|
export function displayConfigPath(configPath, environment) {
|
|
44
103
|
const paths = pathModule(environment.platform);
|
|
45
104
|
const relative = paths.relative(environment.homeDirectory, configPath);
|
|
46
|
-
if (relative !== "" &&
|
|
105
|
+
if (relative !== "" &&
|
|
106
|
+
relative !== ".." &&
|
|
107
|
+
!relative.startsWith(`..${paths.sep}`)) {
|
|
47
108
|
return `~/${relative.split(paths.sep).join("/")}`;
|
|
48
109
|
}
|
|
49
110
|
return configPath;
|
|
50
111
|
}
|
|
51
|
-
export function buildAgentEntry(extraRoots, platform) {
|
|
112
|
+
export function buildAgentEntry(target, extraRoots, platform) {
|
|
52
113
|
const entry = {
|
|
53
114
|
command: "npx",
|
|
54
|
-
args: ["-y",
|
|
115
|
+
args: ["-y", PACKAGE_SPEC],
|
|
55
116
|
};
|
|
117
|
+
const env = {};
|
|
118
|
+
if (target === "hermes")
|
|
119
|
+
env.CUE_API_KEY = "${CUE_API_KEY}";
|
|
56
120
|
if (extraRoots.length > 0) {
|
|
57
|
-
|
|
58
|
-
OMNI_ALLOWED_ROOTS: extraRoots.join(platform === "win32" ? ";" : ":"),
|
|
59
|
-
};
|
|
121
|
+
env.OMNI_ALLOWED_ROOTS = extraRoots.join(platform === "win32" ? ";" : ":");
|
|
60
122
|
}
|
|
123
|
+
if (Object.keys(env).length > 0)
|
|
124
|
+
entry.env = env;
|
|
61
125
|
return entry;
|
|
62
126
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
127
|
+
function configFormat(target) {
|
|
128
|
+
return target === "hermes" ? "hermes-yaml" : "json";
|
|
129
|
+
}
|
|
130
|
+
function reloadInstruction(target) {
|
|
131
|
+
if (target === "hermes")
|
|
132
|
+
return "Run /reload-mcp in Hermes.";
|
|
133
|
+
if (target === "cursor")
|
|
134
|
+
return "Reload the Cursor window.";
|
|
135
|
+
if (target === "claude-desktop")
|
|
136
|
+
return "Restart Claude Desktop.";
|
|
137
|
+
return "Reload the Agent MCP configuration.";
|
|
138
|
+
}
|
|
139
|
+
async function pathDetails(filePath) {
|
|
140
|
+
try {
|
|
141
|
+
return await lstat(filePath);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code === "ENOENT")
|
|
145
|
+
return undefined;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
66
148
|
}
|
|
67
|
-
async function
|
|
149
|
+
async function validateAuxiliaryPaths(configPath, allowLock) {
|
|
150
|
+
for (const [kind, candidate] of [
|
|
151
|
+
["lock", `${configPath}.omni-reader.lock`],
|
|
152
|
+
["backup", agentBackupPath(configPath)],
|
|
153
|
+
]) {
|
|
154
|
+
const details = await pathDetails(candidate);
|
|
155
|
+
if (details === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
158
|
+
throw fileError(`The Agent configuration ${kind} path is unsafe; no changes were written.`);
|
|
159
|
+
}
|
|
160
|
+
if (kind === "lock" && !allowLock) {
|
|
161
|
+
throw fileError("Another Omni setup is updating this Agent configuration; retry after it finishes.");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function validateUserConfigPath(configPath, environment, allowLock = false) {
|
|
68
166
|
const paths = pathModule(environment.platform);
|
|
69
167
|
const homeDirectory = paths.resolve(environment.homeDirectory);
|
|
70
168
|
const userRoots = [homeDirectory];
|
|
@@ -76,39 +174,56 @@ async function validateUserConfigPath(configPath, environment) {
|
|
|
76
174
|
if (userRoot === undefined) {
|
|
77
175
|
throw fileError("The Agent configuration path is outside the user profile; no changes were written.");
|
|
78
176
|
}
|
|
177
|
+
let canonicalUserRoot;
|
|
178
|
+
try {
|
|
179
|
+
canonicalUserRoot = await realpath(userRoot);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw fileError("The Agent user profile could not be resolved safely; no changes were written.");
|
|
183
|
+
}
|
|
184
|
+
const rootDetails = await lstat(userRoot);
|
|
185
|
+
if (!rootDetails.isDirectory() || rootDetails.isSymbolicLink()) {
|
|
186
|
+
throw fileError("The Agent user profile is unsafe; no changes were written.");
|
|
187
|
+
}
|
|
79
188
|
const relativeParent = paths.relative(userRoot, paths.dirname(resolvedConfigPath));
|
|
80
189
|
const components = relativeParent === "" ? [] : relativeParent.split(paths.sep);
|
|
81
190
|
let current = userRoot;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
catch (error) {
|
|
92
|
-
if (error.code === "ENOENT")
|
|
93
|
-
break;
|
|
94
|
-
throw error;
|
|
191
|
+
let nearestExisting = userRoot;
|
|
192
|
+
for (const component of components) {
|
|
193
|
+
current = paths.join(current, component);
|
|
194
|
+
const details = await pathDetails(current);
|
|
195
|
+
if (details === undefined)
|
|
196
|
+
break;
|
|
197
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
198
|
+
throw fileError("The Agent configuration path contains an unsafe directory; no changes were written.");
|
|
95
199
|
}
|
|
200
|
+
nearestExisting = current;
|
|
96
201
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
202
|
+
const canonicalExisting = await realpath(nearestExisting);
|
|
203
|
+
if (!containsPath(canonicalUserRoot, canonicalExisting, paths)) {
|
|
204
|
+
throw fileError("The Agent configuration path escapes the user profile; no changes were written.");
|
|
205
|
+
}
|
|
206
|
+
const configDetails = await pathDetails(resolvedConfigPath);
|
|
207
|
+
if (configDetails !== undefined) {
|
|
208
|
+
if (!configDetails.isFile() || configDetails.isSymbolicLink()) {
|
|
100
209
|
throw fileError("The existing Agent configuration is unsafe; no changes were written.");
|
|
101
210
|
}
|
|
211
|
+
const canonicalConfig = await realpath(resolvedConfigPath);
|
|
212
|
+
if (!containsPath(canonicalUserRoot, canonicalConfig, paths)) {
|
|
213
|
+
throw fileError("The Agent configuration path escapes the user profile; no changes were written.");
|
|
214
|
+
}
|
|
102
215
|
}
|
|
103
|
-
|
|
104
|
-
if (error.code !== "ENOENT")
|
|
105
|
-
throw error;
|
|
106
|
-
}
|
|
216
|
+
await validateAuxiliaryPaths(resolvedConfigPath, allowLock);
|
|
107
217
|
}
|
|
108
|
-
function configFingerprint(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
218
|
+
function configFingerprint(serialized, details) {
|
|
219
|
+
const digest = createHash("sha256").update(serialized, "utf8").digest("hex");
|
|
220
|
+
return [
|
|
221
|
+
`dev=${details.dev}`,
|
|
222
|
+
`ino=${details.ino}`,
|
|
223
|
+
`size=${details.size}`,
|
|
224
|
+
`mtime_ms=${details.mtimeMs}`,
|
|
225
|
+
`sha256:${digest}`,
|
|
226
|
+
].join(";");
|
|
112
227
|
}
|
|
113
228
|
async function readConfig(configPath) {
|
|
114
229
|
let handle;
|
|
@@ -117,37 +232,438 @@ async function readConfig(configPath) {
|
|
|
117
232
|
}
|
|
118
233
|
catch (error) {
|
|
119
234
|
const code = error.code;
|
|
120
|
-
if (code === "ENOENT")
|
|
121
|
-
return { existed: false,
|
|
235
|
+
if (code === "ENOENT") {
|
|
236
|
+
return { existed: false, serialized: "", fingerprint: "missing" };
|
|
237
|
+
}
|
|
122
238
|
if (code === "ELOOP" || code === "EMLINK") {
|
|
123
239
|
throw fileError("The existing Agent configuration is unsafe; no changes were written.");
|
|
124
240
|
}
|
|
125
241
|
throw fileError("The existing Agent configuration could not be read.");
|
|
126
242
|
}
|
|
127
|
-
let serialized;
|
|
128
243
|
try {
|
|
129
244
|
const details = await handle.stat();
|
|
130
245
|
if (!details.isFile()) {
|
|
131
246
|
throw fileError("The existing Agent configuration is unsafe; no changes were written.");
|
|
132
247
|
}
|
|
133
|
-
serialized = await handle.readFile("utf8");
|
|
248
|
+
const serialized = await handle.readFile("utf8");
|
|
249
|
+
return {
|
|
250
|
+
existed: true,
|
|
251
|
+
serialized,
|
|
252
|
+
fingerprint: configFingerprint(serialized, details),
|
|
253
|
+
};
|
|
134
254
|
}
|
|
135
255
|
finally {
|
|
136
256
|
await handle.close();
|
|
137
257
|
}
|
|
258
|
+
}
|
|
259
|
+
function parseJsonConfig(loaded) {
|
|
260
|
+
if (!loaded.existed)
|
|
261
|
+
return {};
|
|
138
262
|
try {
|
|
139
|
-
const parsed = JSON.parse(serialized);
|
|
263
|
+
const parsed = JSON.parse(loaded.serialized);
|
|
140
264
|
if (!isRecord(parsed))
|
|
141
265
|
throw new Error("configuration is not an object");
|
|
142
|
-
return
|
|
266
|
+
return parsed;
|
|
143
267
|
}
|
|
144
268
|
catch {
|
|
145
269
|
throw fileError("The existing Agent configuration is malformed JSON; no changes were written.");
|
|
146
270
|
}
|
|
147
271
|
}
|
|
272
|
+
function jsonStringToken(serialized, start) {
|
|
273
|
+
let escaped = false;
|
|
274
|
+
for (let index = start + 1; index < serialized.length; index += 1) {
|
|
275
|
+
const character = serialized[index];
|
|
276
|
+
if (escaped) {
|
|
277
|
+
escaped = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (character === "\\") {
|
|
281
|
+
escaped = true;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (character === '"') {
|
|
285
|
+
const token = serialized.slice(start, index + 1);
|
|
286
|
+
const value = JSON.parse(token);
|
|
287
|
+
if (typeof value !== "string")
|
|
288
|
+
throw new Error("invalid JSON string");
|
|
289
|
+
return { value, end: index + 1 };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
throw new Error("unterminated JSON string");
|
|
293
|
+
}
|
|
294
|
+
function skipJsonWhitespace(serialized, start) {
|
|
295
|
+
let index = start;
|
|
296
|
+
while (/\s/u.test(serialized[index] ?? ""))
|
|
297
|
+
index += 1;
|
|
298
|
+
return index;
|
|
299
|
+
}
|
|
300
|
+
function mcpServerObjectRange(serialized) {
|
|
301
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
302
|
+
if (serialized[index] !== '"')
|
|
303
|
+
continue;
|
|
304
|
+
const token = jsonStringToken(serialized, index);
|
|
305
|
+
index = token.end - 1;
|
|
306
|
+
if (token.value !== "mcpServers")
|
|
307
|
+
continue;
|
|
308
|
+
let cursor = skipJsonWhitespace(serialized, token.end);
|
|
309
|
+
if (serialized[cursor] !== ":")
|
|
310
|
+
continue;
|
|
311
|
+
cursor = skipJsonWhitespace(serialized, cursor + 1);
|
|
312
|
+
if (serialized[cursor] !== "{")
|
|
313
|
+
continue;
|
|
314
|
+
const start = cursor;
|
|
315
|
+
let depth = 0;
|
|
316
|
+
for (; cursor < serialized.length; cursor += 1) {
|
|
317
|
+
if (serialized[cursor] === '"') {
|
|
318
|
+
cursor = jsonStringToken(serialized, cursor).end - 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (serialized[cursor] === "{")
|
|
322
|
+
depth += 1;
|
|
323
|
+
if (serialized[cursor] === "}" && --depth === 0) {
|
|
324
|
+
return { start, end: cursor + 1 };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
function assertNoDuplicateJsonOmniEntries(serialized) {
|
|
331
|
+
const range = mcpServerObjectRange(serialized);
|
|
332
|
+
if (range === undefined)
|
|
333
|
+
return;
|
|
334
|
+
let depth = 0;
|
|
335
|
+
let count = 0;
|
|
336
|
+
for (let index = range.start; index < range.end; index += 1) {
|
|
337
|
+
const character = serialized[index];
|
|
338
|
+
if (character === '"') {
|
|
339
|
+
const token = jsonStringToken(serialized, index);
|
|
340
|
+
if (depth === 1) {
|
|
341
|
+
const cursor = skipJsonWhitespace(serialized, token.end);
|
|
342
|
+
if (serialized[cursor] === ":" && normalizedOmniName(token.value))
|
|
343
|
+
count += 1;
|
|
344
|
+
}
|
|
345
|
+
index = token.end - 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (character === "{" || character === "[")
|
|
349
|
+
depth += 1;
|
|
350
|
+
if (character === "}" || character === "]")
|
|
351
|
+
depth -= 1;
|
|
352
|
+
}
|
|
353
|
+
if (count > 1) {
|
|
354
|
+
throw fileError("The Agent configuration contains duplicate Omni entries; no changes were written.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function sourceLines(serialized) {
|
|
358
|
+
const lines = [];
|
|
359
|
+
let start = 0;
|
|
360
|
+
while (start < serialized.length) {
|
|
361
|
+
const newline = serialized.indexOf("\n", start);
|
|
362
|
+
const end = newline === -1 ? serialized.length : newline + 1;
|
|
363
|
+
lines.push({ start, end, text: serialized.slice(start, end).replace(/\r?\n$/u, "") });
|
|
364
|
+
start = end;
|
|
365
|
+
}
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
function parseYamlName(value) {
|
|
369
|
+
const trimmed = value.trim();
|
|
370
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(trimmed);
|
|
373
|
+
if (typeof parsed === "string")
|
|
374
|
+
return parsed;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// Rejected below.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
381
|
+
return trimmed.slice(1, -1).replace(/''/gu, "'");
|
|
382
|
+
}
|
|
383
|
+
if (/^[A-Za-z0-9_.-]+$/u.test(trimmed))
|
|
384
|
+
return trimmed;
|
|
385
|
+
throw fileError("The Hermes MCP configuration uses an unsupported server name; no changes were written.");
|
|
386
|
+
}
|
|
387
|
+
function parseHermesDocument(serialized) {
|
|
388
|
+
const lines = sourceLines(serialized);
|
|
389
|
+
const newline = serialized.includes("\r\n") ? "\r\n" : "\n";
|
|
390
|
+
const allBlockHeaders = lines.filter((line) => /^mcp_servers:/u.test(line.text));
|
|
391
|
+
const blockHeaders = allBlockHeaders.filter((line) => /^mcp_servers:\s*(?:#.*)?$/u.test(line.text));
|
|
392
|
+
if (allBlockHeaders.length !== blockHeaders.length) {
|
|
393
|
+
throw fileError("The Hermes mcp_servers setting must use a block mapping; no changes were written.");
|
|
394
|
+
}
|
|
395
|
+
if (blockHeaders.length > 1) {
|
|
396
|
+
throw fileError("The Hermes configuration contains duplicate mcp_servers blocks; no changes were written.");
|
|
397
|
+
}
|
|
398
|
+
if (blockHeaders.length === 0)
|
|
399
|
+
return { entries: [], newline };
|
|
400
|
+
const blockHeader = blockHeaders[0];
|
|
401
|
+
const headerIndex = lines.indexOf(blockHeader);
|
|
402
|
+
let blockEnd = serialized.length;
|
|
403
|
+
for (let index = headerIndex + 1; index < lines.length; index += 1) {
|
|
404
|
+
const text = lines[index].text;
|
|
405
|
+
if (/^[^\s#][^:]*:/u.test(text)) {
|
|
406
|
+
blockEnd = lines[index].start;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const entries = [];
|
|
411
|
+
const headers = [];
|
|
412
|
+
for (let index = headerIndex + 1; index < lines.length; index += 1) {
|
|
413
|
+
const line = lines[index];
|
|
414
|
+
if (line.start >= blockEnd)
|
|
415
|
+
break;
|
|
416
|
+
const match = /^ (\S(?:.*\S)?):\s*(?:#.*)?$/u.exec(line.text);
|
|
417
|
+
if (match !== null)
|
|
418
|
+
headers.push({ name: parseYamlName(match[1]), start: line.start });
|
|
419
|
+
}
|
|
420
|
+
for (let index = 0; index < headers.length; index += 1) {
|
|
421
|
+
const header = headers[index];
|
|
422
|
+
const end = headers[index + 1]?.start ?? blockEnd;
|
|
423
|
+
entries.push({
|
|
424
|
+
name: header.name,
|
|
425
|
+
start: header.start,
|
|
426
|
+
end,
|
|
427
|
+
serialized: serialized.slice(header.start, end),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
blockStart: blockHeader.start,
|
|
432
|
+
blockEnd,
|
|
433
|
+
entries,
|
|
434
|
+
newline,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function parseYamlScalar(value) {
|
|
438
|
+
const trimmed = value.trim();
|
|
439
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
440
|
+
try {
|
|
441
|
+
return JSON.parse(trimmed);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
throw fileError("The Hermes Omni entry contains invalid YAML; no changes were written.");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
448
|
+
return trimmed.slice(1, -1).replace(/''/gu, "'");
|
|
449
|
+
}
|
|
450
|
+
if (trimmed === "true")
|
|
451
|
+
return true;
|
|
452
|
+
if (trimmed === "false")
|
|
453
|
+
return false;
|
|
454
|
+
return trimmed;
|
|
455
|
+
}
|
|
456
|
+
function parseHermesEntry(entry) {
|
|
457
|
+
const lines = sourceLines(entry.serialized).map((line) => line.text);
|
|
458
|
+
const value = {};
|
|
459
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
460
|
+
const match = /^ ([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$/u.exec(lines[index]);
|
|
461
|
+
if (match === null)
|
|
462
|
+
continue;
|
|
463
|
+
const key = match[1];
|
|
464
|
+
const scalar = match[2] ?? "";
|
|
465
|
+
if (key === "args" && scalar === "") {
|
|
466
|
+
const args = [];
|
|
467
|
+
while (index + 1 < lines.length) {
|
|
468
|
+
const item = /^ -\s+(.+)$/u.exec(lines[index + 1]);
|
|
469
|
+
if (item === null)
|
|
470
|
+
break;
|
|
471
|
+
args.push(parseYamlScalar(item[1]));
|
|
472
|
+
index += 1;
|
|
473
|
+
}
|
|
474
|
+
value.args = args;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (key === "env" && scalar === "") {
|
|
478
|
+
const env = {};
|
|
479
|
+
while (index + 1 < lines.length) {
|
|
480
|
+
const item = /^ ([A-Za-z_][A-Za-z0-9_]*):\s+(.+)$/u.exec(lines[index + 1]);
|
|
481
|
+
if (item === null)
|
|
482
|
+
break;
|
|
483
|
+
env[item[1]] = parseYamlScalar(item[2]);
|
|
484
|
+
index += 1;
|
|
485
|
+
}
|
|
486
|
+
value.env = env;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
value[key] = parseYamlScalar(scalar);
|
|
490
|
+
}
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
function yamlEntry(entry, newline) {
|
|
494
|
+
const args = entry.args;
|
|
495
|
+
const lines = [
|
|
496
|
+
" omni-reader:",
|
|
497
|
+
" command: npx",
|
|
498
|
+
" args:",
|
|
499
|
+
...args.map((argument) => ` - ${JSON.stringify(argument)}`),
|
|
500
|
+
];
|
|
501
|
+
if (isRecord(entry.env)) {
|
|
502
|
+
lines.push(" env:");
|
|
503
|
+
for (const [key, value] of Object.entries(entry.env)) {
|
|
504
|
+
lines.push(` ${key}: ${JSON.stringify(value)}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return `${lines.join(newline)}${newline}`;
|
|
508
|
+
}
|
|
509
|
+
function replaceHermesEntry(serialized, document, replacement) {
|
|
510
|
+
const existing = document.entries.find((entry) => entry.name === "omni-reader");
|
|
511
|
+
if (existing !== undefined) {
|
|
512
|
+
return serialized.slice(0, existing.start) + replacement + serialized.slice(existing.end);
|
|
513
|
+
}
|
|
514
|
+
if (document.blockEnd !== undefined) {
|
|
515
|
+
return serialized.slice(0, document.blockEnd) + replacement + serialized.slice(document.blockEnd);
|
|
516
|
+
}
|
|
517
|
+
const separator = serialized.length === 0 || serialized.endsWith("\n") ? "" : document.newline;
|
|
518
|
+
return `${serialized}${separator}mcp_servers:${document.newline}${replacement}`;
|
|
519
|
+
}
|
|
520
|
+
function removeHermesEntry(serialized, document, replacement) {
|
|
521
|
+
const existing = document.entries.find((entry) => entry.name === "omni-reader");
|
|
522
|
+
if (existing === undefined)
|
|
523
|
+
return serialized;
|
|
524
|
+
return serialized.slice(0, existing.start)
|
|
525
|
+
+ (replacement ?? "")
|
|
526
|
+
+ serialized.slice(existing.end);
|
|
527
|
+
}
|
|
528
|
+
function entryDigest(entry) {
|
|
529
|
+
const identity = {
|
|
530
|
+
command: entry.command,
|
|
531
|
+
args: entry.args,
|
|
532
|
+
...(isRecord(entry.env) && entry.env.CUE_API_KEY !== undefined
|
|
533
|
+
? { cue_api_key_reference: entry.env.CUE_API_KEY }
|
|
534
|
+
: {}),
|
|
535
|
+
};
|
|
536
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex")}`;
|
|
537
|
+
}
|
|
538
|
+
function isExpectedOmniEntry(target, value) {
|
|
539
|
+
if (!isRecord(value))
|
|
540
|
+
return false;
|
|
541
|
+
const args = value.args;
|
|
542
|
+
if (value.command !== "npx" ||
|
|
543
|
+
!Array.isArray(args) ||
|
|
544
|
+
args.length !== 2 ||
|
|
545
|
+
args[0] !== "-y" ||
|
|
546
|
+
args[1] !== PACKAGE_SPEC)
|
|
547
|
+
return false;
|
|
548
|
+
const keys = Object.keys(value).sort();
|
|
549
|
+
if (value.env === undefined) {
|
|
550
|
+
return target !== "hermes"
|
|
551
|
+
&& keys.length === 2
|
|
552
|
+
&& keys[0] === "args"
|
|
553
|
+
&& keys[1] === "command";
|
|
554
|
+
}
|
|
555
|
+
if (!isRecord(value.env))
|
|
556
|
+
return false;
|
|
557
|
+
const envKeys = Object.keys(value.env).sort();
|
|
558
|
+
if (target === "hermes") {
|
|
559
|
+
if (value.env.CUE_API_KEY !== "${CUE_API_KEY}")
|
|
560
|
+
return false;
|
|
561
|
+
if (value.env.OMNI_ALLOWED_ROOTS !== undefined &&
|
|
562
|
+
(typeof value.env.OMNI_ALLOWED_ROOTS !== "string" || value.env.OMNI_ALLOWED_ROOTS.length === 0))
|
|
563
|
+
return false;
|
|
564
|
+
if (envKeys.length < 1 ||
|
|
565
|
+
envKeys.length > 2 ||
|
|
566
|
+
envKeys[0] !== "CUE_API_KEY" ||
|
|
567
|
+
(envKeys.length === 2 && envKeys[1] !== "OMNI_ALLOWED_ROOTS"))
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
if (envKeys.length !== 1 ||
|
|
572
|
+
envKeys[0] !== "OMNI_ALLOWED_ROOTS" ||
|
|
573
|
+
typeof value.env.OMNI_ALLOWED_ROOTS !== "string" ||
|
|
574
|
+
value.env.OMNI_ALLOWED_ROOTS.length === 0)
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
return keys.length === 3
|
|
578
|
+
&& keys[0] === "args"
|
|
579
|
+
&& keys[1] === "command"
|
|
580
|
+
&& keys[2] === "env";
|
|
581
|
+
}
|
|
582
|
+
function isLegacyBridgeEntry(target, value) {
|
|
583
|
+
if (!isRecord(value) || value.command !== "npx")
|
|
584
|
+
return false;
|
|
585
|
+
const args = value.args;
|
|
586
|
+
if (!Array.isArray(args) ||
|
|
587
|
+
args.length !== 2 ||
|
|
588
|
+
args[0] !== "-y" ||
|
|
589
|
+
(args[1] !== LEGACY_PACKAGE_SPEC && args[1] !== PACKAGE_SPEC))
|
|
590
|
+
return false;
|
|
591
|
+
if (value.env === undefined)
|
|
592
|
+
return target !== "hermes";
|
|
593
|
+
if (!isRecord(value.env) || !safeCredentialReferences(value.env))
|
|
594
|
+
return false;
|
|
595
|
+
const allowed = target === "hermes"
|
|
596
|
+
? new Set(["CUE_API_KEY", "OMNI_ALLOWED_ROOTS"])
|
|
597
|
+
: new Set(["OMNI_ALLOWED_ROOTS"]);
|
|
598
|
+
return Object.keys(value.env).every((key) => allowed.has(key));
|
|
599
|
+
}
|
|
600
|
+
function isCanonicalRemoteEntry(value) {
|
|
601
|
+
return isRecord(value)
|
|
602
|
+
&& value.url === REMOTE_OMNI_MCP_URL
|
|
603
|
+
&& value.command === undefined
|
|
604
|
+
&& value.args === undefined
|
|
605
|
+
&& safeCredentialReferences(value);
|
|
606
|
+
}
|
|
607
|
+
function onlyOmniEntry(servers) {
|
|
608
|
+
const keys = Object.keys(servers).filter(normalizedOmniName);
|
|
609
|
+
if (keys.length > 1) {
|
|
610
|
+
throw fileError("The Agent configuration contains duplicate Omni entries; no changes were written.");
|
|
611
|
+
}
|
|
612
|
+
const key = keys[0];
|
|
613
|
+
if (key !== undefined && key !== "omni-reader") {
|
|
614
|
+
throw fileError("The Agent configuration contains a conflicting Omni entry; no changes were written.");
|
|
615
|
+
}
|
|
616
|
+
return key === undefined ? {} : { key, value: servers[key] };
|
|
617
|
+
}
|
|
618
|
+
function hermesOmniEntry(document) {
|
|
619
|
+
const entries = document.entries.filter((entry) => normalizedOmniName(entry.name));
|
|
620
|
+
if (entries.length > 1) {
|
|
621
|
+
throw fileError("The Hermes configuration contains duplicate Omni entries; no changes were written.");
|
|
622
|
+
}
|
|
623
|
+
const entry = entries[0];
|
|
624
|
+
if (entry !== undefined && entry.name !== "omni-reader") {
|
|
625
|
+
throw fileError("The Hermes configuration contains a conflicting Omni entry; no changes were written.");
|
|
626
|
+
}
|
|
627
|
+
return entry;
|
|
628
|
+
}
|
|
629
|
+
function previousJsonEntry(value) {
|
|
630
|
+
if (value === undefined)
|
|
631
|
+
return null;
|
|
632
|
+
if (isCanonicalRemoteEntry(value))
|
|
633
|
+
return { format: "json", value };
|
|
634
|
+
if (isLegacyBridgeEntry("cursor", value))
|
|
635
|
+
return null;
|
|
636
|
+
throw fileError("The existing Omni entry is not a trusted remote or Bridge configuration; no changes were written.");
|
|
637
|
+
}
|
|
638
|
+
function hermesCredentialsAreReferences(serialized) {
|
|
639
|
+
for (const line of sourceLines(serialized)) {
|
|
640
|
+
const match = /^\s+([A-Za-z_][A-Za-z0-9_-]*):\s+(.+)$/u.exec(line.text);
|
|
641
|
+
if (match === null ||
|
|
642
|
+
!/authorization|api[_-]?key|token|secret|password/iu.test(match[1]))
|
|
643
|
+
continue;
|
|
644
|
+
const value = parseYamlScalar(match[2]);
|
|
645
|
+
if (value !== "${CUE_API_KEY}" && value !== "Bearer ${CUE_API_KEY}")
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
function previousHermesEntry(entry) {
|
|
651
|
+
if (entry === undefined)
|
|
652
|
+
return null;
|
|
653
|
+
const value = parseHermesEntry(entry);
|
|
654
|
+
if (isCanonicalRemoteEntry(value)) {
|
|
655
|
+
if (!hermesCredentialsAreReferences(entry.serialized)) {
|
|
656
|
+
throw fileError("The Hermes Omni entry contains a literal credential; no changes were written.");
|
|
657
|
+
}
|
|
658
|
+
return { format: "hermes-yaml", serialized: entry.serialized };
|
|
659
|
+
}
|
|
660
|
+
if (isLegacyBridgeEntry("hermes", value))
|
|
661
|
+
return null;
|
|
662
|
+
throw fileError("The existing Hermes Omni entry is not a trusted remote or Bridge configuration; no changes were written.");
|
|
663
|
+
}
|
|
148
664
|
export async function prepareAgentConfig(target, extraRoots, environment) {
|
|
665
|
+
const entry = buildAgentEntry(target, extraRoots, environment.platform);
|
|
149
666
|
const configPath = agentConfigPath(target, environment);
|
|
150
|
-
const entry = buildAgentEntry(extraRoots, environment.platform);
|
|
151
667
|
if (configPath === undefined) {
|
|
152
668
|
return {
|
|
153
669
|
target: "generic",
|
|
@@ -156,31 +672,78 @@ export async function prepareAgentConfig(target, extraRoots, environment) {
|
|
|
156
672
|
before: {},
|
|
157
673
|
after: { mcpServers: { "omni-reader": entry } },
|
|
158
674
|
entry,
|
|
675
|
+
reload: reloadInstruction("generic"),
|
|
159
676
|
};
|
|
160
677
|
}
|
|
678
|
+
const nativeTarget = target;
|
|
161
679
|
await validateUserConfigPath(configPath, environment);
|
|
162
680
|
const loaded = await readConfig(configPath);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
681
|
+
assertNoLiteralApiKey(loaded.serialized, environment);
|
|
682
|
+
const format = configFormat(nativeTarget);
|
|
683
|
+
if (format === "json") {
|
|
684
|
+
assertNoDuplicateJsonOmniEntries(loaded.serialized);
|
|
685
|
+
const value = parseJsonConfig(loaded);
|
|
686
|
+
const existingServers = value.mcpServers;
|
|
687
|
+
if (existingServers !== undefined && !isRecord(existingServers)) {
|
|
688
|
+
throw fileError("The existing mcpServers setting is not an object; no changes were written.");
|
|
689
|
+
}
|
|
690
|
+
const servers = existingServers ?? {};
|
|
691
|
+
const existing = onlyOmniEntry(servers);
|
|
692
|
+
const previousEntry = previousJsonEntry(existing.value);
|
|
693
|
+
const afterValue = {
|
|
694
|
+
...value,
|
|
695
|
+
mcpServers: {
|
|
696
|
+
...servers,
|
|
697
|
+
"omni-reader": entry,
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
return {
|
|
701
|
+
target: nativeTarget,
|
|
702
|
+
configPath,
|
|
703
|
+
displayPath: displayConfigPath(configPath, environment),
|
|
704
|
+
existed: loaded.existed,
|
|
705
|
+
before: redactSecrets(value),
|
|
706
|
+
after: redactSecrets(afterValue),
|
|
707
|
+
entry,
|
|
708
|
+
sourceFingerprint: loaded.fingerprint,
|
|
709
|
+
environment,
|
|
710
|
+
reload: reloadInstruction(nativeTarget),
|
|
711
|
+
format,
|
|
712
|
+
serializedBefore: loaded.serialized,
|
|
713
|
+
serializedAfter: `${JSON.stringify(afterValue, null, 2)}\n`,
|
|
714
|
+
previousEntry,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
const document = parseHermesDocument(loaded.serialized);
|
|
718
|
+
const existing = hermesOmniEntry(document);
|
|
719
|
+
const previousEntry = previousHermesEntry(existing);
|
|
720
|
+
const serializedAfter = replaceHermesEntry(loaded.serialized, document, yamlEntry(entry, document.newline));
|
|
174
721
|
return {
|
|
175
|
-
target,
|
|
722
|
+
target: "hermes",
|
|
176
723
|
configPath,
|
|
177
724
|
displayPath: displayConfigPath(configPath, environment),
|
|
178
725
|
existed: loaded.existed,
|
|
179
|
-
before:
|
|
180
|
-
|
|
726
|
+
before: {
|
|
727
|
+
format: "hermes-yaml",
|
|
728
|
+
other_settings: "preserved",
|
|
729
|
+
omni_reader: existing === undefined
|
|
730
|
+
? "not configured"
|
|
731
|
+
: isCanonicalRemoteEntry(parseHermesEntry(existing))
|
|
732
|
+
? "remote"
|
|
733
|
+
: "bridge",
|
|
734
|
+
},
|
|
735
|
+
after: {
|
|
736
|
+
format: "hermes-yaml",
|
|
737
|
+
other_settings: "preserved",
|
|
738
|
+
mcp_servers: { "omni-reader": entry },
|
|
739
|
+
},
|
|
181
740
|
entry,
|
|
182
|
-
sourceFingerprint:
|
|
741
|
+
sourceFingerprint: loaded.fingerprint,
|
|
183
742
|
environment,
|
|
743
|
+
reload: reloadInstruction("hermes"),
|
|
744
|
+
format,
|
|
745
|
+
serializedAfter,
|
|
746
|
+
previousEntry,
|
|
184
747
|
};
|
|
185
748
|
}
|
|
186
749
|
async function syncDirectory(directory) {
|
|
@@ -198,14 +761,27 @@ async function syncDirectory(directory) {
|
|
|
198
761
|
await handle?.close();
|
|
199
762
|
}
|
|
200
763
|
}
|
|
764
|
+
async function ensureConfigDirectory(directory) {
|
|
765
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
766
|
+
const details = await lstat(directory);
|
|
767
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
768
|
+
throw fileError("The Agent configuration directory is unsafe; no changes were written.");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
201
771
|
async function atomicReplace(filePath, content) {
|
|
202
772
|
const directory = path.dirname(filePath);
|
|
203
|
-
await
|
|
204
|
-
await
|
|
773
|
+
await ensureConfigDirectory(directory);
|
|
774
|
+
const existing = await pathDetails(filePath);
|
|
775
|
+
if (existing !== undefined && (existing.isSymbolicLink() || !existing.isFile())) {
|
|
776
|
+
throw fileError("The Agent configuration target is unsafe; no changes were written.");
|
|
777
|
+
}
|
|
205
778
|
const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${randomBytes(18).toString("base64url")}.tmp`);
|
|
206
779
|
let handle;
|
|
207
780
|
try {
|
|
208
|
-
handle = await open(temporaryPath,
|
|
781
|
+
handle = await open(temporaryPath, fsConstants.O_WRONLY |
|
|
782
|
+
fsConstants.O_CREAT |
|
|
783
|
+
fsConstants.O_EXCL |
|
|
784
|
+
fsConstants.O_NOFOLLOW, 0o600);
|
|
209
785
|
await handle.writeFile(content, "utf8");
|
|
210
786
|
await handle.sync();
|
|
211
787
|
await handle.close();
|
|
@@ -216,20 +792,28 @@ async function atomicReplace(filePath, content) {
|
|
|
216
792
|
}
|
|
217
793
|
finally {
|
|
218
794
|
await handle?.close();
|
|
219
|
-
await
|
|
795
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
220
796
|
}
|
|
221
797
|
}
|
|
222
|
-
async function withConfigLock(configPath, action) {
|
|
798
|
+
async function withConfigLock(configPath, environment, action) {
|
|
223
799
|
const directory = path.dirname(configPath);
|
|
224
|
-
await
|
|
225
|
-
await
|
|
800
|
+
await validateUserConfigPath(configPath, environment);
|
|
801
|
+
await ensureConfigDirectory(directory);
|
|
802
|
+
await validateUserConfigPath(configPath, environment);
|
|
226
803
|
const lockPath = `${configPath}.omni-reader.lock`;
|
|
227
804
|
let handle;
|
|
228
805
|
try {
|
|
229
|
-
handle = await open(lockPath,
|
|
806
|
+
handle = await open(lockPath, fsConstants.O_WRONLY |
|
|
807
|
+
fsConstants.O_CREAT |
|
|
808
|
+
fsConstants.O_EXCL |
|
|
809
|
+
fsConstants.O_NOFOLLOW, 0o600);
|
|
230
810
|
}
|
|
231
811
|
catch (error) {
|
|
232
812
|
if (error.code === "EEXIST") {
|
|
813
|
+
const details = await pathDetails(lockPath);
|
|
814
|
+
if (details?.isSymbolicLink() === true || details !== undefined && !details.isFile()) {
|
|
815
|
+
throw fileError("The Agent configuration lock path is unsafe; no changes were written.");
|
|
816
|
+
}
|
|
233
817
|
throw fileError("Another Omni setup is updating this Agent configuration; retry after it finishes.");
|
|
234
818
|
}
|
|
235
819
|
throw error;
|
|
@@ -237,76 +821,179 @@ async function withConfigLock(configPath, action) {
|
|
|
237
821
|
try {
|
|
238
822
|
await handle.writeFile(`${process.pid}\n`, "utf8");
|
|
239
823
|
await handle.sync();
|
|
824
|
+
await validateUserConfigPath(configPath, environment, true);
|
|
240
825
|
return await action();
|
|
241
826
|
}
|
|
242
827
|
finally {
|
|
243
828
|
await handle.close();
|
|
244
|
-
await
|
|
829
|
+
await unlink(lockPath).catch(() => undefined);
|
|
245
830
|
await syncDirectory(directory);
|
|
246
831
|
}
|
|
247
832
|
}
|
|
833
|
+
function trustedBackup(prepared) {
|
|
834
|
+
return {
|
|
835
|
+
version: 1,
|
|
836
|
+
target: prepared.target,
|
|
837
|
+
source_fingerprint: prepared.sourceFingerprint,
|
|
838
|
+
bridge_entry_digest: entryDigest(prepared.entry),
|
|
839
|
+
previous_entry: prepared.previousEntry ?? null,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function parsePreviousEntry(value) {
|
|
843
|
+
if (value === null)
|
|
844
|
+
return null;
|
|
845
|
+
if (!isRecord(value))
|
|
846
|
+
return undefined;
|
|
847
|
+
if (value.format === "json" && "value" in value) {
|
|
848
|
+
return { format: "json", value: value.value };
|
|
849
|
+
}
|
|
850
|
+
if (value.format === "hermes-yaml" && typeof value.serialized === "string") {
|
|
851
|
+
return { format: "hermes-yaml", serialized: value.serialized };
|
|
852
|
+
}
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
function parseTrustedBackup(value) {
|
|
856
|
+
if (!isRecord(value))
|
|
857
|
+
return undefined;
|
|
858
|
+
const previous = parsePreviousEntry(value.previous_entry);
|
|
859
|
+
if (value.version !== 1 ||
|
|
860
|
+
!["hermes", "cursor", "claude-desktop"].includes(String(value.target)) ||
|
|
861
|
+
typeof value.source_fingerprint !== "string" ||
|
|
862
|
+
typeof value.bridge_entry_digest !== "string" ||
|
|
863
|
+
previous === undefined)
|
|
864
|
+
return undefined;
|
|
865
|
+
return {
|
|
866
|
+
version: 1,
|
|
867
|
+
target: value.target,
|
|
868
|
+
source_fingerprint: value.source_fingerprint,
|
|
869
|
+
bridge_entry_digest: value.bridge_entry_digest,
|
|
870
|
+
previous_entry: previous,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
async function readTrustedBackup(configPath) {
|
|
874
|
+
const backupPath = agentBackupPath(configPath);
|
|
875
|
+
let handle;
|
|
876
|
+
try {
|
|
877
|
+
handle = await open(backupPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
if (error.code === "ENOENT")
|
|
881
|
+
return undefined;
|
|
882
|
+
throw fileError("The trusted Omni backup is unsafe or unreadable.");
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
const details = await handle.stat();
|
|
886
|
+
if (!details.isFile() ||
|
|
887
|
+
(process.platform !== "win32" && (details.mode & 0o077) !== 0)) {
|
|
888
|
+
throw fileError("The trusted Omni backup has unsafe permissions.");
|
|
889
|
+
}
|
|
890
|
+
const parsed = parseTrustedBackup(JSON.parse(await handle.readFile("utf8")));
|
|
891
|
+
if (parsed === undefined)
|
|
892
|
+
throw new Error("invalid backup");
|
|
893
|
+
return parsed;
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
if (error instanceof Error && error.name === "AgentConfigError")
|
|
897
|
+
throw error;
|
|
898
|
+
throw fileError("The trusted Omni backup is malformed.");
|
|
899
|
+
}
|
|
900
|
+
finally {
|
|
901
|
+
await handle.close();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
248
904
|
export async function verifyPreparedAgentConfig(prepared) {
|
|
249
|
-
if (prepared.configPath === undefined
|
|
250
|
-
|
|
251
|
-
|
|
905
|
+
if (prepared.configPath === undefined ||
|
|
906
|
+
prepared.environment === undefined ||
|
|
907
|
+
prepared.sourceFingerprint === undefined)
|
|
252
908
|
return;
|
|
253
909
|
await validateUserConfigPath(prepared.configPath, prepared.environment);
|
|
254
910
|
const current = await readConfig(prepared.configPath);
|
|
255
|
-
if (
|
|
911
|
+
if (current.fingerprint !== prepared.sourceFingerprint) {
|
|
256
912
|
throw fileError("The Agent configuration changed after preview; no changes were written.");
|
|
257
913
|
}
|
|
258
914
|
}
|
|
259
915
|
export async function writePreparedAgentConfig(prepared) {
|
|
260
|
-
if (prepared.configPath === undefined
|
|
916
|
+
if (prepared.configPath === undefined ||
|
|
917
|
+
prepared.environment === undefined ||
|
|
918
|
+
prepared.sourceFingerprint === undefined ||
|
|
919
|
+
prepared.serializedAfter === undefined)
|
|
261
920
|
return;
|
|
262
|
-
await withConfigLock(prepared.configPath, async () => {
|
|
263
|
-
await verifyPreparedAgentConfig(prepared);
|
|
921
|
+
await withConfigLock(prepared.configPath, prepared.environment, async () => {
|
|
264
922
|
const current = await readConfig(prepared.configPath);
|
|
265
|
-
if (
|
|
266
|
-
|
|
923
|
+
if (current.fingerprint !== prepared.sourceFingerprint) {
|
|
924
|
+
throw fileError("The Agent configuration changed after preview; no changes were written.");
|
|
925
|
+
}
|
|
926
|
+
const backup = trustedBackup(prepared);
|
|
927
|
+
const backupPath = agentBackupPath(prepared.configPath);
|
|
928
|
+
const existingBackup = await readTrustedBackup(prepared.configPath);
|
|
929
|
+
if (existingBackup === undefined) {
|
|
930
|
+
await atomicReplace(backupPath, `${JSON.stringify(backup, null, 2)}\n`);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
const currentEntryMatches = await configHasExpectedEntry(prepared.target, current);
|
|
934
|
+
if (currentEntryMatches && existingBackup.target === prepared.target) {
|
|
935
|
+
const updatedBackup = {
|
|
936
|
+
...existingBackup,
|
|
937
|
+
bridge_entry_digest: entryDigest(prepared.entry),
|
|
938
|
+
};
|
|
939
|
+
if (JSON.stringify(updatedBackup) !== JSON.stringify(existingBackup)) {
|
|
940
|
+
await atomicReplace(backupPath, `${JSON.stringify(updatedBackup, null, 2)}\n`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
else if (JSON.stringify(existingBackup) !== JSON.stringify(backup)) {
|
|
944
|
+
throw fileError("A conflicting trusted Omni backup already exists; no changes were written.");
|
|
945
|
+
}
|
|
267
946
|
}
|
|
268
|
-
await
|
|
947
|
+
const immediatelyBeforeWrite = await readConfig(prepared.configPath);
|
|
948
|
+
if (immediatelyBeforeWrite.fingerprint !== current.fingerprint) {
|
|
949
|
+
throw fileError("The Agent configuration changed during setup; no changes were written.");
|
|
950
|
+
}
|
|
951
|
+
await atomicReplace(prepared.configPath, prepared.serializedAfter);
|
|
269
952
|
});
|
|
270
953
|
}
|
|
271
|
-
function
|
|
272
|
-
if (!isRecord(value))
|
|
273
|
-
return
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return false;
|
|
282
|
-
if (value.env === undefined) {
|
|
283
|
-
return keys.length === 2 && keys[0] === "args" && keys[1] === "command";
|
|
954
|
+
function jsonConfigEntry(value) {
|
|
955
|
+
if (!isRecord(value.mcpServers))
|
|
956
|
+
return undefined;
|
|
957
|
+
return onlyOmniEntry(value.mcpServers).value;
|
|
958
|
+
}
|
|
959
|
+
async function configHasExpectedEntry(target, loaded) {
|
|
960
|
+
if (target === "hermes") {
|
|
961
|
+
const document = parseHermesDocument(loaded.serialized);
|
|
962
|
+
const entry = hermesOmniEntry(document);
|
|
963
|
+
return entry !== undefined && isExpectedOmniEntry(target, parseHermesEntry(entry));
|
|
284
964
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return keys.length === 3
|
|
289
|
-
&& keys[0] === "args"
|
|
290
|
-
&& keys[1] === "command"
|
|
291
|
-
&& keys[2] === "env"
|
|
292
|
-
&& envKeys.length === 1
|
|
293
|
-
&& envKeys[0] === "OMNI_ALLOWED_ROOTS"
|
|
294
|
-
&& typeof value.env.OMNI_ALLOWED_ROOTS === "string"
|
|
295
|
-
&& value.env.OMNI_ALLOWED_ROOTS.length > 0;
|
|
965
|
+
assertNoDuplicateJsonOmniEntries(loaded.serialized);
|
|
966
|
+
const value = parseJsonConfig(loaded);
|
|
967
|
+
return isExpectedOmniEntry(target, jsonConfigEntry(value));
|
|
296
968
|
}
|
|
297
|
-
export async function inspectAgentConfig(configPath, environment) {
|
|
969
|
+
export async function inspectAgentConfig(configPath, environment, target) {
|
|
298
970
|
try {
|
|
299
971
|
if (environment !== undefined)
|
|
300
972
|
await validateUserConfigPath(configPath, environment);
|
|
301
973
|
const loaded = await readConfig(configPath);
|
|
302
974
|
if (!loaded.existed)
|
|
303
975
|
return "not configured";
|
|
304
|
-
|
|
976
|
+
const resolvedTarget = target ?? (configPath.endsWith("config.yaml") ? "hermes" : "cursor");
|
|
977
|
+
if (resolvedTarget === "hermes") {
|
|
978
|
+
const document = parseHermesDocument(loaded.serialized);
|
|
979
|
+
const entry = hermesOmniEntry(document);
|
|
980
|
+
if (entry === undefined)
|
|
981
|
+
return "not configured";
|
|
982
|
+
const value = parseHermesEntry(entry);
|
|
983
|
+
if (isExpectedOmniEntry("hermes", value))
|
|
984
|
+
return "configured";
|
|
985
|
+
return isCanonicalRemoteEntry(value) ? "not configured" : "invalid or unreadable";
|
|
986
|
+
}
|
|
987
|
+
assertNoDuplicateJsonOmniEntries(loaded.serialized);
|
|
988
|
+
const value = parseJsonConfig(loaded);
|
|
989
|
+
if (!isRecord(value.mcpServers))
|
|
305
990
|
return "not configured";
|
|
306
|
-
const entry =
|
|
991
|
+
const entry = onlyOmniEntry(value.mcpServers).value;
|
|
307
992
|
if (entry === undefined)
|
|
308
993
|
return "not configured";
|
|
309
|
-
|
|
994
|
+
if (isExpectedOmniEntry(resolvedTarget, entry))
|
|
995
|
+
return "configured";
|
|
996
|
+
return isCanonicalRemoteEntry(entry) ? "not configured" : "invalid or unreadable";
|
|
310
997
|
}
|
|
311
998
|
catch {
|
|
312
999
|
return "invalid or unreadable";
|
|
@@ -314,7 +1001,7 @@ export async function inspectAgentConfig(configPath, environment) {
|
|
|
314
1001
|
}
|
|
315
1002
|
export async function detectAgentTargets(environment) {
|
|
316
1003
|
const detected = [];
|
|
317
|
-
for (const target of ["cursor", "claude-desktop"]) {
|
|
1004
|
+
for (const target of ["hermes", "cursor", "claude-desktop"]) {
|
|
318
1005
|
const configPath = agentConfigPath(target, environment);
|
|
319
1006
|
if (configPath === undefined)
|
|
320
1007
|
continue;
|
|
@@ -333,3 +1020,119 @@ export async function detectAgentTargets(environment) {
|
|
|
333
1020
|
export async function configContainsOmni(configPath) {
|
|
334
1021
|
return await inspectAgentConfig(configPath) === "configured";
|
|
335
1022
|
}
|
|
1023
|
+
export async function configuredAllowedRoots(target, environment) {
|
|
1024
|
+
const configPath = agentConfigPath(target, environment);
|
|
1025
|
+
if (configPath === undefined || target === "generic")
|
|
1026
|
+
return [];
|
|
1027
|
+
await validateUserConfigPath(configPath, environment);
|
|
1028
|
+
const loaded = await readConfig(configPath);
|
|
1029
|
+
if (!loaded.existed)
|
|
1030
|
+
return [];
|
|
1031
|
+
let entry;
|
|
1032
|
+
if (target === "hermes") {
|
|
1033
|
+
const hermesEntry = hermesOmniEntry(parseHermesDocument(loaded.serialized));
|
|
1034
|
+
entry = hermesEntry === undefined ? undefined : parseHermesEntry(hermesEntry);
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
assertNoDuplicateJsonOmniEntries(loaded.serialized);
|
|
1038
|
+
entry = jsonConfigEntry(parseJsonConfig(loaded));
|
|
1039
|
+
}
|
|
1040
|
+
if (!isRecord(entry) || !isRecord(entry.env))
|
|
1041
|
+
return [];
|
|
1042
|
+
const roots = entry.env.OMNI_ALLOWED_ROOTS;
|
|
1043
|
+
if (typeof roots !== "string" || roots.length === 0)
|
|
1044
|
+
return [];
|
|
1045
|
+
const separator = environment.platform === "win32" ? ";" : ":";
|
|
1046
|
+
return roots.split(separator).filter((root) => root.length > 0);
|
|
1047
|
+
}
|
|
1048
|
+
export async function rollbackPreparedAgentConfig(prepared) {
|
|
1049
|
+
if (prepared.configPath === undefined ||
|
|
1050
|
+
prepared.environment === undefined ||
|
|
1051
|
+
prepared.serializedBefore === undefined ||
|
|
1052
|
+
prepared.serializedAfter === undefined)
|
|
1053
|
+
return;
|
|
1054
|
+
await withConfigLock(prepared.configPath, prepared.environment, async () => {
|
|
1055
|
+
const current = await readConfig(prepared.configPath);
|
|
1056
|
+
if (!current.existed || current.serialized !== prepared.serializedAfter) {
|
|
1057
|
+
throw fileError("The Agent configuration changed before rollback; automatic rollback stopped.");
|
|
1058
|
+
}
|
|
1059
|
+
if (prepared.existed) {
|
|
1060
|
+
await atomicReplace(prepared.configPath, prepared.serializedBefore);
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
await unlink(prepared.configPath);
|
|
1064
|
+
await syncDirectory(path.dirname(prepared.configPath));
|
|
1065
|
+
}
|
|
1066
|
+
await unlink(agentBackupPath(prepared.configPath)).catch((error) => {
|
|
1067
|
+
if (error.code !== "ENOENT")
|
|
1068
|
+
throw error;
|
|
1069
|
+
});
|
|
1070
|
+
await syncDirectory(path.dirname(prepared.configPath));
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
function restoreJsonEntry(serialized, previous) {
|
|
1074
|
+
const loaded = { existed: true, serialized, fingerprint: "unused" };
|
|
1075
|
+
const value = parseJsonConfig(loaded);
|
|
1076
|
+
const servers = isRecord(value.mcpServers) ? { ...value.mcpServers } : {};
|
|
1077
|
+
delete servers["omni-reader"];
|
|
1078
|
+
if (previous?.format === "json")
|
|
1079
|
+
servers["omni-reader"] = previous.value;
|
|
1080
|
+
return `${JSON.stringify({ ...value, mcpServers: servers }, null, 2)}\n`;
|
|
1081
|
+
}
|
|
1082
|
+
function restoreHermesEntry(serialized, previous) {
|
|
1083
|
+
const document = parseHermesDocument(serialized);
|
|
1084
|
+
return removeHermesEntry(serialized, document, previous?.format === "hermes-yaml" ? previous.serialized : undefined);
|
|
1085
|
+
}
|
|
1086
|
+
function trustedRemotePrevious(previous) {
|
|
1087
|
+
if (previous === null)
|
|
1088
|
+
return null;
|
|
1089
|
+
if (previous.format === "json") {
|
|
1090
|
+
return isCanonicalRemoteEntry(previous.value) ? previous : null;
|
|
1091
|
+
}
|
|
1092
|
+
const document = parseHermesDocument(`mcp_servers:\n${previous.serialized}`);
|
|
1093
|
+
const entry = hermesOmniEntry(document);
|
|
1094
|
+
return entry !== undefined && isCanonicalRemoteEntry(parseHermesEntry(entry))
|
|
1095
|
+
? previous
|
|
1096
|
+
: null;
|
|
1097
|
+
}
|
|
1098
|
+
export async function uninstallAgentConfig(target, environment) {
|
|
1099
|
+
const configPath = agentConfigPath(target, environment);
|
|
1100
|
+
if (configPath === undefined)
|
|
1101
|
+
return undefined;
|
|
1102
|
+
await validateUserConfigPath(configPath, environment);
|
|
1103
|
+
const initial = await readConfig(configPath);
|
|
1104
|
+
if (!initial.existed || !await configHasExpectedEntry(target, initial))
|
|
1105
|
+
return undefined;
|
|
1106
|
+
return await withConfigLock(configPath, environment, async () => {
|
|
1107
|
+
const current = await readConfig(configPath);
|
|
1108
|
+
if (!await configHasExpectedEntry(target, current)) {
|
|
1109
|
+
throw fileError("The Agent configuration changed before uninstall; no changes were written.");
|
|
1110
|
+
}
|
|
1111
|
+
const currentEntry = target === "hermes"
|
|
1112
|
+
? parseHermesEntry(hermesOmniEntry(parseHermesDocument(current.serialized)))
|
|
1113
|
+
: jsonConfigEntry(parseJsonConfig(current));
|
|
1114
|
+
const backup = await readTrustedBackup(configPath);
|
|
1115
|
+
const matchingBackup = backup !== undefined
|
|
1116
|
+
&& backup.target === target
|
|
1117
|
+
&& backup.bridge_entry_digest === entryDigest(currentEntry)
|
|
1118
|
+
? backup
|
|
1119
|
+
: undefined;
|
|
1120
|
+
const previous = matchingBackup === undefined
|
|
1121
|
+
? null
|
|
1122
|
+
: trustedRemotePrevious(matchingBackup.previous_entry);
|
|
1123
|
+
const serializedAfter = target === "hermes"
|
|
1124
|
+
? restoreHermesEntry(current.serialized, previous)
|
|
1125
|
+
: restoreJsonEntry(current.serialized, previous);
|
|
1126
|
+
const immediatelyBeforeWrite = await readConfig(configPath);
|
|
1127
|
+
if (immediatelyBeforeWrite.fingerprint !== current.fingerprint) {
|
|
1128
|
+
throw fileError("The Agent configuration changed during uninstall; no changes were written.");
|
|
1129
|
+
}
|
|
1130
|
+
await atomicReplace(configPath, serializedAfter);
|
|
1131
|
+
await unlink(agentBackupPath(configPath)).catch((error) => {
|
|
1132
|
+
if (error.code !== "ENOENT")
|
|
1133
|
+
throw error;
|
|
1134
|
+
});
|
|
1135
|
+
await syncDirectory(path.dirname(configPath));
|
|
1136
|
+
return { target, restoredRemote: previous !== null };
|
|
1137
|
+
});
|
|
1138
|
+
}
|