@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.
Files changed (48) hide show
  1. package/README.md +115 -26
  2. package/dist/artifact-store.d.ts +11 -0
  3. package/dist/artifact-store.js +94 -48
  4. package/dist/cli/agent-config.d.ts +29 -4
  5. package/dist/cli/agent-config.js +910 -107
  6. package/dist/cli/arguments.d.ts +32 -0
  7. package/dist/cli/arguments.js +120 -0
  8. package/dist/cli/doctor.d.ts +42 -1
  9. package/dist/cli/doctor.js +109 -36
  10. package/dist/cli/setup.d.ts +3 -0
  11. package/dist/cli/setup.js +103 -18
  12. package/dist/cli/uninstall.d.ts +6 -0
  13. package/dist/cli/uninstall.js +37 -0
  14. package/dist/constants.d.ts +7 -0
  15. package/dist/constants.js +7 -0
  16. package/dist/cube-client.d.ts +5 -3
  17. package/dist/cube-client.js +16 -11
  18. package/dist/cursor.js +2 -0
  19. package/dist/errors.d.ts +32 -1
  20. package/dist/errors.js +26 -1
  21. package/dist/iiis-client.d.ts +18 -4
  22. package/dist/iiis-client.js +194 -40
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.js +93 -32
  25. package/dist/multipart-body.js +2 -0
  26. package/dist/onboarding-policy.d.ts +10 -0
  27. package/dist/onboarding-policy.js +58 -0
  28. package/dist/operation-journal.d.ts +50 -1
  29. package/dist/operation-journal.js +473 -114
  30. package/dist/operation-manager.d.ts +75 -0
  31. package/dist/operation-manager.js +1324 -0
  32. package/dist/path-security.d.ts +1 -0
  33. package/dist/path-security.js +26 -6
  34. package/dist/progress.d.ts +6 -1
  35. package/dist/protocol.d.ts +26 -13
  36. package/dist/protocol.js +34 -10
  37. package/dist/remote-client.d.ts +17 -0
  38. package/dist/remote-client.js +233 -0
  39. package/dist/result-contract.d.ts +199 -0
  40. package/dist/result-contract.js +235 -0
  41. package/dist/server.js +21 -4
  42. package/dist/source.d.ts +8 -0
  43. package/dist/source.js +37 -0
  44. package/dist/task-runtime.d.ts +13 -0
  45. package/dist/task-runtime.js +94 -0
  46. package/dist/tools.d.ts +19 -1
  47. package/dist/tools.js +317 -112
  48. package/package.json +3 -3
@@ -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" || normalized === "claude" || 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 !== "" && relative !== ".." && !relative.startsWith(`..${paths.sep}`)) {
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", "@cueai/omni-reader-mcp"],
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
- entry.env = {
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 containsPath(parent, child, paths) {
64
- const relative = paths.relative(parent, child);
65
- return relative === "" || (relative !== ".." && !relative.startsWith(`..${paths.sep}`));
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 validateUserConfigPath(configPath, environment) {
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
- for (const component of ["", ...components]) {
83
- if (component !== "")
84
- current = paths.join(current, component);
85
- try {
86
- const details = await lstat(current);
87
- if (!details.isDirectory() || details.isSymbolicLink()) {
88
- throw fileError("The Agent configuration path contains an unsafe directory; no changes were written.");
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
- try {
98
- const details = await lstat(resolvedConfigPath);
99
- if (!details.isFile() || details.isSymbolicLink()) {
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
- catch (error) {
104
- if (error.code !== "ENOENT")
105
- throw error;
106
- }
216
+ await validateAuxiliaryPaths(resolvedConfigPath, allowLock);
107
217
  }
108
- function configFingerprint(loaded) {
109
- if (!loaded.existed || loaded.serialized === undefined)
110
- return "missing";
111
- return `sha256:${createHash("sha256").update(loaded.serialized).digest("hex")}`;
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, value: {} };
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 { existed: true, value: parsed, serialized };
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
- const existingServers = loaded.value.mcpServers;
164
- if (existingServers !== undefined && !isRecord(existingServers)) {
165
- throw fileError("The existing mcpServers setting is not an object; no changes were written.");
166
- }
167
- const after = {
168
- ...loaded.value,
169
- mcpServers: {
170
- ...(existingServers ?? {}),
171
- "omni-reader": entry,
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: loaded.value,
180
- after,
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: configFingerprint(loaded),
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 mkdir(directory, { recursive: true, mode: 0o700 });
204
- await chmod(directory, 0o700);
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, "wx", 0o600);
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 import("node:fs/promises").then(({ unlink }) => unlink(temporaryPath).catch(() => undefined));
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 mkdir(directory, { recursive: true, mode: 0o700 });
225
- await chmod(directory, 0o700);
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, "wx", 0o600);
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 import("node:fs/promises").then(({ unlink }) => unlink(lockPath).catch(() => undefined));
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
- || prepared.environment === undefined
251
- || prepared.sourceFingerprint === undefined)
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 (configFingerprint(current) !== prepared.sourceFingerprint) {
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 (prepared.existed && current.serialized !== undefined) {
266
- await atomicReplace(`${prepared.configPath}.bak`, current.serialized);
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 atomicReplace(prepared.configPath, `${JSON.stringify(prepared.after, null, 2)}\n`);
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 isExpectedOmniEntry(value) {
272
- if (!isRecord(value))
273
- return false;
274
- const keys = Object.keys(value).sort();
275
- const args = value.args;
276
- if (value.command !== "npx"
277
- || !Array.isArray(args)
278
- || args.length !== 2
279
- || args[0] !== "-y"
280
- || args[1] !== "@cueai/omni-reader-mcp")
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
- if (!isRecord(value.env))
286
- return false;
287
- const envKeys = Object.keys(value.env);
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
- if (!isRecord(loaded.value.mcpServers))
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 = loaded.value.mcpServers["omni-reader"];
991
+ const entry = onlyOmniEntry(value.mcpServers).value;
307
992
  if (entry === undefined)
308
993
  return "not configured";
309
- return isExpectedOmniEntry(entry) ? "configured" : "invalid or unreadable";
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
+ }