@getmarrow/install 0.1.65 → 0.1.66
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 +77 -22
- package/package.json +1 -1
- package/src/controller-manager.js +152 -6
- package/src/enforcement-client.js +6 -3
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +6 -0
- package/src/governed-runner.js +413 -90
- package/src/hermes-config.js +568 -0
- package/src/installer.js +764 -373
- package/src/owner-env.js +134 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
// Hermes reads MCP servers from mcp_servers in $HERMES_HOME/config.yaml (default ~/.hermes).
|
|
5
|
+
// Stdio servers get only PATH, HOME and a few locale variables plus their own env block, and
|
|
6
|
+
// ${VAR} values are resolved from the Hermes environment, including $HERMES_HOME/.env.
|
|
7
|
+
//
|
|
8
|
+
// This is a deliberately small line editor for one entry, mcp_servers.marrow. It keeps every
|
|
9
|
+
// other byte, comment and key in the file. Anything it cannot edit with certainty is refused
|
|
10
|
+
// and the caller prints the exact block to add by hand: a byte order mark, an indented or
|
|
11
|
+
// non-mapping root, tabs, anchors, aliases, tags, flow mappings, block scalars, duplicate keys,
|
|
12
|
+
// several documents, unterminated top-level quotes or flows, and a marrow entry with a custom
|
|
13
|
+
// command or custom arguments. The planned result is re-analyzed before it is returned: it
|
|
14
|
+
// must hold exactly one mcp_servers mapping, and every line outside the marrow entry must be
|
|
15
|
+
// unchanged. The config holds other servers' credentials, so no copy of it is ever written;
|
|
16
|
+
// the undo note lists only the marrow lines added or replaced, with values redacted.
|
|
17
|
+
|
|
18
|
+
const SERVER_NAME = 'marrow';
|
|
19
|
+
const KEY_REFERENCE = '"${MARROW_API_KEY}"';
|
|
20
|
+
const REDACTED = '[redacted]';
|
|
21
|
+
const MARROW_ARG_RE = /^(?:-y|--yes|marrow-mcp|(?:--package=)?@getmarrow\/mcp(?:@[0-9A-Za-z.+-]+)?)$/;
|
|
22
|
+
const SECRET_FLAG_RE = /^-{1,2}[A-Za-z0-9_.-]*(?:key|token|secret|passw(?:or)?d|auth|credential|bearer|cookie)[A-Za-z0-9_.-]*(?:=.*)?$/i;
|
|
23
|
+
|
|
24
|
+
function hermesHome(home, env = process.env) {
|
|
25
|
+
const configured = String(env.HERMES_HOME || '').trim();
|
|
26
|
+
return configured ? path.resolve(configured) : path.join(home, '.hermes');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hermesPaths(home, env = process.env) {
|
|
30
|
+
const directory = hermesHome(home, env);
|
|
31
|
+
return {
|
|
32
|
+
home: directory,
|
|
33
|
+
config: path.join(directory, 'config.yaml'),
|
|
34
|
+
env: path.join(directory, '.env'),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function desiredArgs(mcpPackageSpec) {
|
|
39
|
+
return ['-y', `--package=${mcpPackageSpec}`, 'marrow-mcp'];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flowList(values) {
|
|
43
|
+
return `[${values.map((value) => JSON.stringify(value)).join(', ')}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function indentOf(line) {
|
|
47
|
+
return line.match(/^ */)[0].length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isBlankOrComment(line) {
|
|
51
|
+
return /^\s*(?:#.*)?$/.test(line);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripComment(value) {
|
|
55
|
+
let quote = null;
|
|
56
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
57
|
+
const char = value[index];
|
|
58
|
+
if (quote) {
|
|
59
|
+
if (char === quote) quote = null;
|
|
60
|
+
} else if (char === '"' || char === "'") {
|
|
61
|
+
quote = char;
|
|
62
|
+
} else if (char === '#' && (index === 0 || /\s/.test(value[index - 1]))) {
|
|
63
|
+
return value.slice(0, index).trimEnd();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return value.trimEnd();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scalarValue(raw) {
|
|
70
|
+
const value = stripComment(String(raw || '')).trim();
|
|
71
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2)
|
|
72
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
|
|
73
|
+
return value.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function keyLine(line) {
|
|
79
|
+
const match = line.match(/^( *)(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+))\s*:(?:\s+(.*)|\s*)$/);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
return {
|
|
82
|
+
indent: match[1].length,
|
|
83
|
+
key: match[2] || match[3] || match[4],
|
|
84
|
+
rest: match[5] === undefined ? '' : match[5],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function unsafeValue(rest) {
|
|
89
|
+
const value = stripComment(rest).trim();
|
|
90
|
+
return /^[&*!|>]/.test(value) || value.startsWith('<<');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Scans the text a node starts on (a key's value or a sequence item) and every following line
|
|
94
|
+
// until a quoted or flow scalar closes. Returns the lines it spans past the first, or null
|
|
95
|
+
// when the value closes on its own line.
|
|
96
|
+
function scalarSpan(lines, start, value) {
|
|
97
|
+
const trimmed = value.trimStart();
|
|
98
|
+
const opener = trimmed[0];
|
|
99
|
+
if (!['"', "'", '[', '{'].includes(opener)) return 0;
|
|
100
|
+
let quote = opener === '"' || opener === "'" ? opener : null;
|
|
101
|
+
let depth = quote ? 0 : 1;
|
|
102
|
+
let index = 1;
|
|
103
|
+
let text = trimmed;
|
|
104
|
+
for (let line = start; line < lines.length; line += 1) {
|
|
105
|
+
if (line > start) {
|
|
106
|
+
text = lines[line];
|
|
107
|
+
index = 0;
|
|
108
|
+
}
|
|
109
|
+
for (; index < text.length; index += 1) {
|
|
110
|
+
const char = text[index];
|
|
111
|
+
if (quote === '"') {
|
|
112
|
+
if (char === '\\') index += 1;
|
|
113
|
+
else if (char === '"') quote = null;
|
|
114
|
+
} else if (quote === "'") {
|
|
115
|
+
if (char === "'" && text[index + 1] === "'") index += 1;
|
|
116
|
+
else if (char === "'") quote = null;
|
|
117
|
+
} else if (char === '"' || char === "'") {
|
|
118
|
+
quote = char;
|
|
119
|
+
} else if (char === '#' && (index === 0 || /\s/.test(text[index - 1]))) {
|
|
120
|
+
break;
|
|
121
|
+
} else if (char === '[' || char === '{') {
|
|
122
|
+
depth += 1;
|
|
123
|
+
} else if (char === ']' || char === '}') {
|
|
124
|
+
depth -= 1;
|
|
125
|
+
}
|
|
126
|
+
if (!quote && depth === 0) return line - start;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return -1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function nodeValue(line) {
|
|
133
|
+
const parsed = keyLine(line);
|
|
134
|
+
if (parsed) return parsed.rest;
|
|
135
|
+
const item = line.match(/^ *- +(.*)$/);
|
|
136
|
+
if (!item) return '';
|
|
137
|
+
const nested = keyLine(`${' '.repeat(indentOf(line) + 2)}${item[1]}`);
|
|
138
|
+
return nested ? nested.rest : item[1];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Marks every line that belongs to a multi-line quoted or flow scalar. Those lines are content,
|
|
142
|
+
// never structure, so a key-looking line inside a string can never be mistaken for a key.
|
|
143
|
+
function scanDocument(text) {
|
|
144
|
+
if (text.charCodeAt(0) === 0xfeff) return { error: 'byte order mark' };
|
|
145
|
+
const lines = text.split(/\r?\n/);
|
|
146
|
+
if (/\t/.test(lines.map((line) => line.match(/^\s*/)[0]).join(''))) return { error: 'tab indentation' };
|
|
147
|
+
const continuation = new Array(lines.length).fill(false);
|
|
148
|
+
let documentStartAllowed = true;
|
|
149
|
+
let rootChecked = false;
|
|
150
|
+
let sequenceParent = false;
|
|
151
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
152
|
+
const line = lines[index];
|
|
153
|
+
if (continuation[index] || isBlankOrComment(line)) continue;
|
|
154
|
+
const indent = indentOf(line);
|
|
155
|
+
if (/^---\s*(?:#.*)?$/.test(line) && documentStartAllowed) {
|
|
156
|
+
documentStartAllowed = false;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
documentStartAllowed = false;
|
|
160
|
+
if (/^---(?:\s|$)/.test(line) || /^\.\.\.(?:\s|$)/.test(line)) return { error: 'multiple YAML documents' };
|
|
161
|
+
if (!rootChecked) {
|
|
162
|
+
rootChecked = true;
|
|
163
|
+
if (indent > 0) return { error: 'indented top-level document' };
|
|
164
|
+
}
|
|
165
|
+
if (indent === 0) {
|
|
166
|
+
const parsed = keyLine(line);
|
|
167
|
+
if (parsed) {
|
|
168
|
+
sequenceParent = !stripComment(parsed.rest).trim();
|
|
169
|
+
} else if (!(sequenceParent && /^-(?:\s|$)/.test(line))) {
|
|
170
|
+
return { error: 'unsupported top-level YAML syntax' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const span = scalarSpan(lines, index, nodeValue(line));
|
|
174
|
+
if (span === -1) return { error: 'unterminated quoted or flow value' };
|
|
175
|
+
if (span > 0) {
|
|
176
|
+
if (indent === 0) return { error: 'unterminated top-level quote or flow' };
|
|
177
|
+
for (let next = index + 1; next <= index + span; next += 1) continuation[next] = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { lines, continuation };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Last line index (exclusive) of the block owned by the key at `start`: the next structural
|
|
184
|
+
// line indented at or below `parentIndent`. Trailing blank and comment lines are left outside.
|
|
185
|
+
function blockEnd(doc, start, parentIndent) {
|
|
186
|
+
const { lines, continuation } = doc;
|
|
187
|
+
const owner = keyLine(lines[start]);
|
|
188
|
+
// PyYAML writes a sequence under a key at the key's own indent (`args:` then `- -y`).
|
|
189
|
+
const sameIndentSequence = Boolean(owner && owner.indent === parentIndent && !stripComment(owner.rest).trim());
|
|
190
|
+
let lastContent = start;
|
|
191
|
+
for (let end = start + 1; end < lines.length; end += 1) {
|
|
192
|
+
const line = lines[end];
|
|
193
|
+
if (continuation[end]) {
|
|
194
|
+
lastContent = end;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (isBlankOrComment(line)) continue;
|
|
198
|
+
const indent = indentOf(line);
|
|
199
|
+
if (indent < parentIndent) break;
|
|
200
|
+
if (indent === parentIndent && !(sameIndentSequence && /^ *-(?:\s|$)/.test(line))) break;
|
|
201
|
+
lastContent = end;
|
|
202
|
+
}
|
|
203
|
+
return lastContent + 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function childKeys(doc, start, end) {
|
|
207
|
+
const { lines, continuation } = doc;
|
|
208
|
+
let childIndent = null;
|
|
209
|
+
const children = [];
|
|
210
|
+
for (let index = start; index < end; index += 1) {
|
|
211
|
+
const line = lines[index];
|
|
212
|
+
if (continuation[index] || isBlankOrComment(line)) continue;
|
|
213
|
+
const indent = indentOf(line);
|
|
214
|
+
if (childIndent === null) childIndent = indent;
|
|
215
|
+
if (indent < childIndent) return { error: 'inconsistent indentation' };
|
|
216
|
+
if (indent === childIndent) {
|
|
217
|
+
const previous = children[children.length - 1];
|
|
218
|
+
if (/^ *-(?:\s|$)/.test(line) && previous && !stripComment(previous.rest).trim()) continue;
|
|
219
|
+
const parsed = keyLine(line);
|
|
220
|
+
if (!parsed) return { error: 'unsupported entry syntax' };
|
|
221
|
+
children.push({ ...parsed, line: index });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const child of children) child.end = Math.min(blockEnd(doc, child.line, childIndent), end);
|
|
225
|
+
return { childIndent, children };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readListValue(doc, child) {
|
|
229
|
+
const { lines } = doc;
|
|
230
|
+
const inline = stripComment(child.rest).trim();
|
|
231
|
+
if (inline) {
|
|
232
|
+
if (!inline.startsWith('[') || !inline.endsWith(']')) return null;
|
|
233
|
+
const body = inline.slice(1, -1).trim();
|
|
234
|
+
if (!body) return [];
|
|
235
|
+
if (/[[\]{}]/.test(body)) return null;
|
|
236
|
+
return body.split(',').map((item) => scalarValue(item));
|
|
237
|
+
}
|
|
238
|
+
const items = [];
|
|
239
|
+
for (let index = child.line + 1; index < child.end; index += 1) {
|
|
240
|
+
const line = lines[index];
|
|
241
|
+
if (isBlankOrComment(line)) continue;
|
|
242
|
+
const match = line.match(/^\s*-\s+(.*)$/);
|
|
243
|
+
if (!match) return null;
|
|
244
|
+
items.push(scalarValue(match[1]));
|
|
245
|
+
}
|
|
246
|
+
return items;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sameList(left, right) {
|
|
250
|
+
return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function analyzeConfig(text) {
|
|
254
|
+
const doc = scanDocument(text);
|
|
255
|
+
if (doc.error) return { error: doc.error };
|
|
256
|
+
const { lines, continuation } = doc;
|
|
257
|
+
const topLevel = lines
|
|
258
|
+
.map((line, index) => ({ line, index }))
|
|
259
|
+
.filter(({ line, index }) => !continuation[index] && /^(?:"mcp_servers"|'mcp_servers'|mcp_servers)\s*:/.test(line));
|
|
260
|
+
if (topLevel.length > 1) return { error: 'duplicate mcp_servers keys' };
|
|
261
|
+
if (topLevel.length === 0) return { doc, lines, servers: null };
|
|
262
|
+
const serversLine = topLevel[0].index;
|
|
263
|
+
const serversKey = keyLine(lines[serversLine]);
|
|
264
|
+
if (!serversKey) return { error: 'unsupported mcp_servers syntax' };
|
|
265
|
+
const inline = stripComment(serversKey.rest).trim();
|
|
266
|
+
if (inline && !['{}', 'null', '~'].includes(inline)) return { error: 'mcp_servers is not a block mapping' };
|
|
267
|
+
const end = blockEnd(doc, serversLine, 0);
|
|
268
|
+
const servers = { line: serversLine, end, emptyInline: Boolean(inline) };
|
|
269
|
+
if (inline) return { doc, lines, servers, entry: null, childIndent: null };
|
|
270
|
+
const parsed = childKeys(doc, serversLine + 1, end);
|
|
271
|
+
if (parsed.error) return { error: `mcp_servers: ${parsed.error}` };
|
|
272
|
+
const entries = parsed.children.filter((child) => child.key === SERVER_NAME);
|
|
273
|
+
if (entries.length > 1) return { error: 'duplicate mcp_servers.marrow keys' };
|
|
274
|
+
return { doc, lines, servers, entry: entries[0] || null, childIndent: parsed.childIndent };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderEntry(indent, unit, options) {
|
|
278
|
+
const pad = ' '.repeat(indent);
|
|
279
|
+
const inner = ' '.repeat(indent + unit);
|
|
280
|
+
const envPad = ' '.repeat(indent + unit * 2);
|
|
281
|
+
const lines = [
|
|
282
|
+
`${pad}${SERVER_NAME}:`,
|
|
283
|
+
`${inner}command: npx`,
|
|
284
|
+
`${inner}args: ${flowList(desiredArgs(options.mcpPackageSpec))}`,
|
|
285
|
+
`${inner}env:`,
|
|
286
|
+
`${envPad}MARROW_CLIENT: hermes`,
|
|
287
|
+
];
|
|
288
|
+
if (options.keyReference) lines.push(`${envPad}MARROW_API_KEY: ${KEY_REFERENCE}`);
|
|
289
|
+
return lines;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function manualBlock(options) {
|
|
293
|
+
return ['mcp_servers:', ...renderEntry(2, 2, options)].join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function redactArgs(items) {
|
|
297
|
+
const out = [];
|
|
298
|
+
let redactNext = false;
|
|
299
|
+
for (const item of items) {
|
|
300
|
+
if (redactNext) {
|
|
301
|
+
out.push(REDACTED);
|
|
302
|
+
redactNext = false;
|
|
303
|
+
} else if (SECRET_FLAG_RE.test(item)) {
|
|
304
|
+
if (item.includes('=')) out.push(`${item.slice(0, item.indexOf('='))}=${REDACTED}`);
|
|
305
|
+
else {
|
|
306
|
+
out.push(item);
|
|
307
|
+
redactNext = true;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
out.push(item);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Undo lines keep structure only: every env value except MARROW_CLIENT is redacted, and so is
|
|
317
|
+
// every argument after a key- or token-like flag. Comments can hold anything, so trailing
|
|
318
|
+
// comments are dropped and comment-only lines are replaced with a marker.
|
|
319
|
+
function redactUndoLines(lines, startInEnv = false) {
|
|
320
|
+
let envIndent = startInEnv ? -1 : null;
|
|
321
|
+
let argsIndent = null;
|
|
322
|
+
let argItems = [];
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const raw of lines) {
|
|
325
|
+
if (/^\s*#/.test(raw)) {
|
|
326
|
+
out.push(`${' '.repeat(indentOf(raw))}# [comment redacted]`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const line = stripComment(raw);
|
|
330
|
+
const indent = indentOf(line);
|
|
331
|
+
if (envIndent !== null && indent <= envIndent && !isBlankOrComment(line)) envIndent = null;
|
|
332
|
+
if (argsIndent !== null && !/^ *-(?:\s|$)/.test(line) && (indent <= argsIndent && !isBlankOrComment(line))) argsIndent = null;
|
|
333
|
+
const parsed = keyLine(line);
|
|
334
|
+
if (envIndent !== null && parsed) {
|
|
335
|
+
out.push(parsed.key === 'MARROW_CLIENT' ? line : `${' '.repeat(parsed.indent)}${parsed.key}: ${REDACTED}`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (argsIndent !== null) {
|
|
339
|
+
const item = line.match(/^( *- +)(.*)$/);
|
|
340
|
+
if (item) {
|
|
341
|
+
argItems.push(scalarValue(item[2]));
|
|
342
|
+
const redacted = redactArgs(argItems);
|
|
343
|
+
out.push(`${item[1]}${JSON.stringify(redacted[redacted.length - 1])}`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (parsed && parsed.key === 'env') {
|
|
348
|
+
envIndent = parsed.indent;
|
|
349
|
+
out.push(stripComment(parsed.rest).trim() ? `${' '.repeat(parsed.indent)}env: ${REDACTED}` : line);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (parsed) {
|
|
353
|
+
// Any list is treated like command arguments: items after a secret-like flag are redacted.
|
|
354
|
+
const inline = stripComment(parsed.rest).trim();
|
|
355
|
+
if (inline.startsWith('[') && inline.endsWith(']')) {
|
|
356
|
+
const body = inline.slice(1, -1).trim();
|
|
357
|
+
const items = body ? body.split(',').map((item) => scalarValue(item)) : [];
|
|
358
|
+
out.push(`${' '.repeat(parsed.indent)}${parsed.key}: ${flowList(redactArgs(items))}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!inline) {
|
|
362
|
+
argsIndent = parsed.indent;
|
|
363
|
+
argItems = [];
|
|
364
|
+
out.push(line);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
out.push(['command', 'type', 'timeout', 'enabled'].includes(parsed.key) || ['{}', 'null', '~'].includes(inline)
|
|
368
|
+
? line
|
|
369
|
+
: `${' '.repeat(parsed.indent)}${parsed.key}: ${REDACTED}`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
out.push(line);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function splitLines(text) {
|
|
378
|
+
const lines = text.split(/\r?\n/);
|
|
379
|
+
if (lines.length > 0 && lines[lines.length - 1] === '' && /\r?\n$/.test(text)) lines.pop();
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Every line outside mcp_servers.marrow, with an empty inline mcp_servers written as a block key.
|
|
384
|
+
function linesOutsideEntry(text) {
|
|
385
|
+
const analysis = analyzeConfig(text);
|
|
386
|
+
if (analysis.error) return null;
|
|
387
|
+
const lines = splitLines(text);
|
|
388
|
+
return lines.flatMap((line, index) => {
|
|
389
|
+
if (analysis.entry && index >= analysis.entry.line && index < analysis.entry.end) return [];
|
|
390
|
+
if (analysis.servers && index === analysis.servers.line) return ['mcp_servers:'];
|
|
391
|
+
return [line];
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function verifyPlanned(before, after, options) {
|
|
396
|
+
const analysis = analyzeConfig(after);
|
|
397
|
+
if (analysis.error || !analysis.servers || analysis.servers.emptyInline || !analysis.entry) return false;
|
|
398
|
+
if (planHermesMcpConfig(after, { ...options, verify: false }).action !== 'unchanged') return false;
|
|
399
|
+
const expected = linesOutsideEntry(before);
|
|
400
|
+
let actual = linesOutsideEntry(after);
|
|
401
|
+
if (!expected || !actual) return false;
|
|
402
|
+
if (!analyzeConfig(before).servers) {
|
|
403
|
+
const serversIndex = actual.lastIndexOf('mcp_servers:');
|
|
404
|
+
if (serversIndex < 0) return false;
|
|
405
|
+
actual = actual.filter((_, index) => index !== serversIndex);
|
|
406
|
+
if (actual.length === expected.length + 1 && actual[actual.length - 1] === '') actual.pop();
|
|
407
|
+
}
|
|
408
|
+
return actual.length === expected.length && actual.every((line, index) => line === expected[index]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Returns { action: 'unchanged' | 'update' | 'refuse', content?, undo?, reason?, hosted? }.
|
|
412
|
+
function planHermesMcpConfig(text, options) {
|
|
413
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
414
|
+
const analysis = analyzeConfig(text);
|
|
415
|
+
if (analysis.error) return { action: 'refuse', reason: analysis.error };
|
|
416
|
+
const { doc } = analysis;
|
|
417
|
+
const lines = [...analysis.lines];
|
|
418
|
+
const trailingNewline = text.length === 0 || /\r?\n$/.test(text);
|
|
419
|
+
if (lines.length > 0 && lines[lines.length - 1] === '' && trailingNewline) lines.pop();
|
|
420
|
+
const detectedUnit = (() => {
|
|
421
|
+
const indented = lines.find((line) => !isBlankOrComment(line) && indentOf(line) > 0);
|
|
422
|
+
return indented ? indentOf(indented) : 2;
|
|
423
|
+
})();
|
|
424
|
+
const undo = [];
|
|
425
|
+
const finish = (next) => {
|
|
426
|
+
const content = `${next.join(eol)}${trailingNewline || next.length !== lines.length ? eol : ''}`;
|
|
427
|
+
if (content === text) return { action: 'unchanged' };
|
|
428
|
+
if (options.verify !== false && !verifyPlanned(text, content, options)) {
|
|
429
|
+
return { action: 'refuse', reason: 'the edited file did not verify' };
|
|
430
|
+
}
|
|
431
|
+
return { action: 'update', content, undo };
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (!analysis.servers) {
|
|
435
|
+
const separator = lines.length && lines[lines.length - 1].trim() !== '' ? [''] : [];
|
|
436
|
+
const added = ['mcp_servers:', ...renderEntry(detectedUnit, detectedUnit, options)];
|
|
437
|
+
undo.push({ change: 'added', lines: redactUndoLines(added) });
|
|
438
|
+
return finish([...lines, ...separator, ...added]);
|
|
439
|
+
}
|
|
440
|
+
const { servers } = analysis;
|
|
441
|
+
if (servers.emptyInline) {
|
|
442
|
+
const added = ['mcp_servers:', ...renderEntry(detectedUnit, detectedUnit, options)];
|
|
443
|
+
undo.push({ change: 'replaced', replaced: redactUndoLines([lines[servers.line]]), lines: redactUndoLines(added) });
|
|
444
|
+
const next = [...lines];
|
|
445
|
+
next.splice(servers.line, 1, ...added);
|
|
446
|
+
return finish(next);
|
|
447
|
+
}
|
|
448
|
+
const childIndent = analysis.childIndent ?? detectedUnit;
|
|
449
|
+
if (!analysis.entry) {
|
|
450
|
+
const added = renderEntry(childIndent, childIndent, options);
|
|
451
|
+
undo.push({ change: 'added', lines: redactUndoLines(added) });
|
|
452
|
+
const next = [...lines];
|
|
453
|
+
next.splice(servers.end, 0, ...added);
|
|
454
|
+
return finish(next);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const entry = analysis.entry;
|
|
458
|
+
if (stripComment(entry.rest).trim()) return { action: 'refuse', reason: 'mcp_servers.marrow is not a block mapping' };
|
|
459
|
+
for (let index = entry.line + 1; index < entry.end; index += 1) {
|
|
460
|
+
if (doc.continuation[index]) continue;
|
|
461
|
+
const parsed = keyLine(lines[index]);
|
|
462
|
+
if ((parsed && unsafeValue(parsed.rest)) || /(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)/.test(stripComment(lines[index]))) {
|
|
463
|
+
return { action: 'refuse', reason: 'mcp_servers.marrow uses anchors, aliases, tags or block scalars' };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const fields = childKeys(doc, entry.line + 1, entry.end);
|
|
467
|
+
if (fields.error) return { action: 'refuse', reason: `mcp_servers.marrow: ${fields.error}` };
|
|
468
|
+
const fieldIndent = fields.childIndent ?? childIndent * 2;
|
|
469
|
+
const unit = Math.max(1, fieldIndent - childIndent);
|
|
470
|
+
const byKey = new Map();
|
|
471
|
+
for (const field of fields.children) {
|
|
472
|
+
if (byKey.has(field.key)) return { action: 'refuse', reason: `duplicate mcp_servers.marrow.${field.key}` };
|
|
473
|
+
byKey.set(field.key, field);
|
|
474
|
+
}
|
|
475
|
+
if (byKey.has('url')) return { action: 'unchanged', hosted: true };
|
|
476
|
+
|
|
477
|
+
const replacements = [];
|
|
478
|
+
const inserts = [];
|
|
479
|
+
const pad = ' '.repeat(fieldIndent);
|
|
480
|
+
const command = byKey.get('command');
|
|
481
|
+
if (command && (command.end !== command.line + 1 || scalarValue(command.rest) !== 'npx')) {
|
|
482
|
+
return { action: 'refuse', reason: 'mcp_servers.marrow has a custom command' };
|
|
483
|
+
}
|
|
484
|
+
if (!command) inserts.push(`${pad}command: npx`);
|
|
485
|
+
const args = byKey.get('args');
|
|
486
|
+
const wanted = desiredArgs(options.mcpPackageSpec);
|
|
487
|
+
if (!args) inserts.push(`${pad}args: ${flowList(wanted)}`);
|
|
488
|
+
else {
|
|
489
|
+
const current = readListValue(doc, args);
|
|
490
|
+
if (!current || !current.every((item) => MARROW_ARG_RE.test(item))) {
|
|
491
|
+
return { action: 'refuse', reason: 'mcp_servers.marrow has custom args' };
|
|
492
|
+
}
|
|
493
|
+
if (!sameList(current, wanted)) {
|
|
494
|
+
replacements.push({ start: args.line, end: args.end, lines: [`${pad}args: ${flowList(wanted)}`] });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const envField = byKey.get('env');
|
|
498
|
+
const envPad = ' '.repeat(fieldIndent + unit);
|
|
499
|
+
if (!envField) {
|
|
500
|
+
inserts.push(`${pad}env:`, `${envPad}MARROW_CLIENT: hermes`);
|
|
501
|
+
if (options.keyReference) inserts.push(`${envPad}MARROW_API_KEY: ${KEY_REFERENCE}`);
|
|
502
|
+
} else {
|
|
503
|
+
const inlineEnv = stripComment(envField.rest).trim();
|
|
504
|
+
if (inlineEnv && inlineEnv !== '{}') return { action: 'refuse', reason: 'mcp_servers.marrow.env is not a block mapping' };
|
|
505
|
+
const envKeys = inlineEnv ? { childIndent: null, children: [] } : childKeys(doc, envField.line + 1, envField.end);
|
|
506
|
+
if (envKeys.error) return { action: 'refuse', reason: `mcp_servers.marrow.env: ${envKeys.error}` };
|
|
507
|
+
for (const variable of envKeys.children) {
|
|
508
|
+
if (variable.end !== variable.line + 1) return { action: 'refuse', reason: 'mcp_servers.marrow.env has multi-line values' };
|
|
509
|
+
}
|
|
510
|
+
const variablePad = ' '.repeat(envKeys.childIndent ?? fieldIndent + unit);
|
|
511
|
+
const names = new Map(envKeys.children.map((variable) => [variable.key, variable]));
|
|
512
|
+
const envLines = [];
|
|
513
|
+
const client = names.get('MARROW_CLIENT');
|
|
514
|
+
if (!client) envLines.push(`${variablePad}MARROW_CLIENT: hermes`);
|
|
515
|
+
else if (scalarValue(client.rest) !== 'hermes') {
|
|
516
|
+
replacements.push({ start: client.line, end: client.end, lines: [`${variablePad}MARROW_CLIENT: hermes`], env: true });
|
|
517
|
+
}
|
|
518
|
+
if (options.keyReference && !names.has('MARROW_API_KEY') && !names.has('MARROW_KEY')) {
|
|
519
|
+
envLines.push(`${variablePad}MARROW_API_KEY: ${KEY_REFERENCE}`);
|
|
520
|
+
}
|
|
521
|
+
if (inlineEnv === '{}') {
|
|
522
|
+
replacements.push({ start: envField.line, end: envField.end, lines: [`${pad}env:`, ...envLines] });
|
|
523
|
+
} else if (envLines.length) {
|
|
524
|
+
replacements.push({ start: envField.end, end: envField.end, lines: envLines, env: true });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const next = [...lines];
|
|
528
|
+
const ordered = [...replacements].sort((left, right) => right.start - left.start);
|
|
529
|
+
if (inserts.length) ordered.unshift({ start: entry.end, end: entry.end, lines: inserts });
|
|
530
|
+
ordered.sort((left, right) => right.start - left.start || right.end - left.end);
|
|
531
|
+
for (const change of [...ordered].reverse()) {
|
|
532
|
+
const replaced = lines.slice(change.start, change.end);
|
|
533
|
+
undo.push(replaced.length
|
|
534
|
+
? { change: 'replaced', replaced: redactUndoLines(replaced, change.env === true), lines: redactUndoLines(change.lines, change.env === true) }
|
|
535
|
+
: { change: 'added', lines: redactUndoLines(change.lines, change.env === true) });
|
|
536
|
+
}
|
|
537
|
+
for (const change of ordered) next.splice(change.start, change.end - change.start, ...change.lines);
|
|
538
|
+
return finish(next);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Names only: the owner's values are never read out of the entry.
|
|
542
|
+
function marrowEnvKeyNames(text) {
|
|
543
|
+
const analysis = analyzeConfig(String(text || ''));
|
|
544
|
+
if (analysis.error || !analysis.entry) return [];
|
|
545
|
+
const fields = childKeys(analysis.doc, analysis.entry.line + 1, analysis.entry.end);
|
|
546
|
+
const env = fields.error ? null : fields.children.find((field) => field.key === 'env');
|
|
547
|
+
if (!env || stripComment(env.rest).trim()) return [];
|
|
548
|
+
const variables = childKeys(analysis.doc, env.line + 1, env.end);
|
|
549
|
+
return variables.error ? [] : variables.children.map((variable) => variable.key);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function hermesEnvHasKey(envPath) {
|
|
553
|
+
try {
|
|
554
|
+
return /^\s*(?:export\s+)?MARROW_API_KEY\s*=\s*\S/m.test(fs.readFileSync(envPath, 'utf8'));
|
|
555
|
+
} catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
module.exports = {
|
|
561
|
+
hermesEnvHasKey,
|
|
562
|
+
hermesHome,
|
|
563
|
+
hermesPaths,
|
|
564
|
+
manualBlock,
|
|
565
|
+
marrowEnvKeyNames,
|
|
566
|
+
planHermesMcpConfig,
|
|
567
|
+
redactUndoLines,
|
|
568
|
+
};
|