@evomap/evolver-mcp 2.0.0-beta.1 → 2.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/antigravityInstaller.d.ts +32 -0
- package/dist/antigravityInstaller.js +271 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/injection.d.ts +4 -3
- package/dist/injection.js +21 -10
- package/dist/installer.d.ts +51 -6
- package/dist/installer.js +28 -6
- package/dist/jsonMcpInstaller.d.ts +75 -0
- package/dist/jsonMcpInstaller.js +824 -0
- package/dist/kiroInstaller.d.ts +10 -0
- package/dist/kiroInstaller.js +146 -0
- package/dist/opencodeInstaller.d.ts +18 -0
- package/dist/opencodeInstaller.js +531 -0
- package/dist/proxyClient.d.ts +17 -0
- package/dist/proxyClient.js +25 -0
- package/dist/tools.js +61 -0
- package/package.json +7 -2
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installer.js';
|
|
6
|
+
const CONFIG_MODE = 0o600;
|
|
7
|
+
const DIR_MODE = 0o700;
|
|
8
|
+
const BACKUP_VERSION = 1;
|
|
9
|
+
const ENV_FILE_KEY = 'EVOLVER_ENV_FILE';
|
|
10
|
+
let beforeReplaceHookForTest;
|
|
11
|
+
let afterReplaceHookForTest;
|
|
12
|
+
let beforeBackupRemoveHookForTest;
|
|
13
|
+
export function _setJsonMcpBeforeReplaceHookForTest(hook) {
|
|
14
|
+
beforeReplaceHookForTest = hook;
|
|
15
|
+
}
|
|
16
|
+
export function _setJsonMcpAfterReplaceHookForTest(hook) {
|
|
17
|
+
afterReplaceHookForTest = hook;
|
|
18
|
+
}
|
|
19
|
+
export function _setJsonMcpBeforeBackupRemoveHookForTest(hook) {
|
|
20
|
+
beforeBackupRemoveHookForTest = hook;
|
|
21
|
+
}
|
|
22
|
+
function removeBackup(path) {
|
|
23
|
+
beforeBackupRemoveHookForTest?.(path);
|
|
24
|
+
rmSync(path);
|
|
25
|
+
}
|
|
26
|
+
export class McpConfigConflictError extends Error {
|
|
27
|
+
diff;
|
|
28
|
+
constructor(runtime, path, expected, actual) {
|
|
29
|
+
const diff = { path: String(sanitizeForError(path, 'path')), expected: sanitizeForError(expected), actual: sanitizeForError(actual) };
|
|
30
|
+
super(`[setup-hooks] refusing to overwrite ${runtime} config: existing Evolver entry conflicts:\n${JSON.stringify(diff, null, 2)}`);
|
|
31
|
+
this.name = 'McpConfigConflictError';
|
|
32
|
+
this.diff = diff;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class McpConfigShapeError extends Error {
|
|
36
|
+
constructor(runtime, path, detail) {
|
|
37
|
+
super(`[setup-hooks] refusing to overwrite ${runtime} config (${path}): ${detail}. Fix the config, then rerun.`);
|
|
38
|
+
this.name = 'McpConfigShapeError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class McpConfigOwnershipError extends Error {
|
|
42
|
+
constructor(runtime, detail) {
|
|
43
|
+
super(`[setup-hooks] refusing to modify ${runtime} configuration: ${detail}. Review the managed backup and runtime config before retrying.`);
|
|
44
|
+
this.name = 'McpConfigOwnershipError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class McpConfigVerificationError extends Error {
|
|
48
|
+
runtime;
|
|
49
|
+
path;
|
|
50
|
+
restored = false;
|
|
51
|
+
constructor(runtime, path) {
|
|
52
|
+
super(`[setup-hooks] ${runtime} config read-back verification failed (${path}); rollback could not be confirmed.`);
|
|
53
|
+
this.runtime = runtime;
|
|
54
|
+
this.path = path;
|
|
55
|
+
this.name = 'McpConfigVerificationError';
|
|
56
|
+
}
|
|
57
|
+
markRestored() {
|
|
58
|
+
this.restored = true;
|
|
59
|
+
this.message = `[setup-hooks] ${this.runtime} config read-back verification failed (${this.path}); the previous config was restored.`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class McpConfigChangedError extends Error {
|
|
63
|
+
constructor(runtime, path) {
|
|
64
|
+
super(`[setup-hooks] refusing to overwrite ${runtime} config (${path}): the file changed after it was read. Review the current config and rerun.`);
|
|
65
|
+
this.name = 'McpConfigChangedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class McpServerValidationError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(`[setup-hooks] ${message}`);
|
|
71
|
+
this.name = 'McpServerValidationError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function isObject(value) {
|
|
75
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
function digest(raw) {
|
|
78
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
function stableEqual(left, right) {
|
|
81
|
+
if (Object.is(left, right))
|
|
82
|
+
return true;
|
|
83
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
84
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
85
|
+
&& left.length === right.length
|
|
86
|
+
&& left.every((value, index) => stableEqual(value, right[index]));
|
|
87
|
+
}
|
|
88
|
+
if (!isObject(left) || !isObject(right))
|
|
89
|
+
return false;
|
|
90
|
+
const leftKeys = Object.keys(left).sort();
|
|
91
|
+
const rightKeys = Object.keys(right).sort();
|
|
92
|
+
return leftKeys.length === rightKeys.length
|
|
93
|
+
&& leftKeys.every((key, index) => key === rightKeys[index] && stableEqual(left[key], right[key]));
|
|
94
|
+
}
|
|
95
|
+
function resolveRuntimeConfig(spec, opts) {
|
|
96
|
+
if (spec.resolveConfig)
|
|
97
|
+
return spec.resolveConfig(opts);
|
|
98
|
+
return {
|
|
99
|
+
configPath: spec.configPath(opts),
|
|
100
|
+
safeRoot: spec.safeRoot?.(opts) ?? (opts.scope === 'user' ? (opts.homeDir ?? homedir()) : opts.configRoot),
|
|
101
|
+
conflictingPaths: spec.conflictingPaths?.(opts) ?? [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function resolveInstallRuntimeConfig(spec, opts) {
|
|
105
|
+
const resolution = resolveRuntimeConfig(spec, opts);
|
|
106
|
+
const candidates = resolution.installDiscoveryPaths
|
|
107
|
+
?? resolution.uninstallCandidatePaths
|
|
108
|
+
?? [resolution.configPath];
|
|
109
|
+
if (candidates.length < 2)
|
|
110
|
+
return resolution;
|
|
111
|
+
const discoveryResolution = resolution.installSafeRoot
|
|
112
|
+
? { ...resolution, safeRoot: resolution.installSafeRoot }
|
|
113
|
+
: resolution;
|
|
114
|
+
const { managedCandidates: backupCandidates, orphanBackupErrors } = inspectManagedCandidates(spec, discoveryResolution, candidates);
|
|
115
|
+
// The active target still fails closed on an orphan backup during validation. An inactive backup only proves
|
|
116
|
+
// ownership while its config still contains the Evolver entry that installation would be retargeted to.
|
|
117
|
+
const managedCandidates = backupCandidates.filter((candidate) => candidateIsActiveOrConfigured(spec, candidate, resolution.configPath));
|
|
118
|
+
const activeBackupError = orphanBackupErrors.find(({ candidate }) => resolve(candidate) === resolve(resolution.configPath));
|
|
119
|
+
if (managedCandidates.length > 1) {
|
|
120
|
+
throw new McpConfigOwnershipError(spec.runtime, 'multiple managed backup candidates exist; remove the ambiguity before retrying');
|
|
121
|
+
}
|
|
122
|
+
if (managedCandidates.length === 0) {
|
|
123
|
+
if (activeBackupError)
|
|
124
|
+
throw activeBackupError.error;
|
|
125
|
+
return resolution;
|
|
126
|
+
}
|
|
127
|
+
const managedPath = managedCandidates[0];
|
|
128
|
+
if (resolve(managedPath) === resolve(resolution.configPath))
|
|
129
|
+
return resolution;
|
|
130
|
+
return {
|
|
131
|
+
...discoveryResolution,
|
|
132
|
+
configPath: managedPath,
|
|
133
|
+
managedRetarget: true,
|
|
134
|
+
topologyCandidatePaths: candidates,
|
|
135
|
+
...(existsSync(resolution.configPath)
|
|
136
|
+
&& dirname(resolve(resolution.configPath)) === dirname(resolve(managedPath))
|
|
137
|
+
? { activePrecedencePath: resolution.configPath }
|
|
138
|
+
: {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function resolveUninstallRuntimeConfig(spec, opts) {
|
|
142
|
+
const resolution = resolveRuntimeConfig(spec, opts);
|
|
143
|
+
const candidates = resolution.uninstallDiscoveryPaths
|
|
144
|
+
?? resolution.uninstallCandidatePaths
|
|
145
|
+
?? [resolution.configPath];
|
|
146
|
+
if (candidates.length < 2)
|
|
147
|
+
return resolution;
|
|
148
|
+
const discoveryResolution = resolution.uninstallSafeRoot
|
|
149
|
+
? { ...resolution, safeRoot: resolution.uninstallSafeRoot }
|
|
150
|
+
: resolution;
|
|
151
|
+
const { managedCandidates, orphanBackupErrors } = inspectManagedCandidates(spec, discoveryResolution, candidates);
|
|
152
|
+
const uninstallCandidates = managedCandidates.filter((candidate) => candidateIsActiveOrConfigured(spec, candidate, resolution.configPath));
|
|
153
|
+
const activeBackupError = orphanBackupErrors.find(({ candidate }) => resolve(candidate) === resolve(resolution.configPath));
|
|
154
|
+
if (uninstallCandidates.length > 1) {
|
|
155
|
+
throw new McpConfigOwnershipError(spec.runtime, 'multiple managed backup candidates exist; remove the ambiguity before retrying');
|
|
156
|
+
}
|
|
157
|
+
if (uninstallCandidates.length === 0) {
|
|
158
|
+
if (activeBackupError)
|
|
159
|
+
throw activeBackupError.error;
|
|
160
|
+
return resolution;
|
|
161
|
+
}
|
|
162
|
+
const configPath = uninstallCandidates[0];
|
|
163
|
+
return {
|
|
164
|
+
...discoveryResolution,
|
|
165
|
+
configPath,
|
|
166
|
+
conflictingPaths: resolution.uninstallConflictingPaths?.(configPath) ?? resolution.conflictingPaths,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function inspectManagedCandidates(spec, resolution, candidates) {
|
|
170
|
+
const managedCandidates = [];
|
|
171
|
+
const orphanBackupErrors = [];
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
assertSafeParents(spec.runtime, resolution.safeRoot, candidate);
|
|
174
|
+
try {
|
|
175
|
+
if (readBackup(candidate))
|
|
176
|
+
managedCandidates.push(candidate);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (!(error instanceof McpConfigShapeError) || candidateContainsEvolver(spec, candidate))
|
|
180
|
+
throw error;
|
|
181
|
+
orphanBackupErrors.push({ candidate, error });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { managedCandidates, orphanBackupErrors };
|
|
185
|
+
}
|
|
186
|
+
function candidateContainsEvolver(spec, configPath) {
|
|
187
|
+
if (!existsSync(configPath))
|
|
188
|
+
return false;
|
|
189
|
+
const snapshot = readSnapshot(spec.runtime, configPath);
|
|
190
|
+
const container = validateContainer(spec.runtime, configPath, snapshot.data, spec.containerKey);
|
|
191
|
+
return Object.prototype.hasOwnProperty.call(container, 'evolver');
|
|
192
|
+
}
|
|
193
|
+
function sanitizeHeaderArgument(value) {
|
|
194
|
+
const headerFlag = value.match(/(^|[\s"'`;=,|&([{])(--header(?=$|[=\s])|-H(?=$|[=\s])|-H(?=[A-Za-z][A-Za-z-]*\s*:))/i);
|
|
195
|
+
if (headerFlag?.index !== undefined) {
|
|
196
|
+
const flagStart = headerFlag.index + headerFlag[1].length;
|
|
197
|
+
const flag = headerFlag[2];
|
|
198
|
+
const suffix = value.slice(flagStart + flag.length);
|
|
199
|
+
return `${value.slice(0, flagStart)}${flag}${suffix.startsWith('=') ? '=' : ' '}<redacted>`;
|
|
200
|
+
}
|
|
201
|
+
const sensitive = value.match(/(^|[\s"'`;=,|&([{])((?:authorization|proxy-authorization|x-api-key|api-key|cookie|set-cookie)\s*:)/i);
|
|
202
|
+
if (sensitive?.index === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
return `${value.slice(0, sensitive.index)}${sensitive[1]}${sensitive[2]} <redacted>`;
|
|
205
|
+
}
|
|
206
|
+
function sanitizeForError(value, key = '') {
|
|
207
|
+
if (isSensitiveName(key) || key === 'env' || key === 'environment') {
|
|
208
|
+
return '<redacted>';
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
let redactNextSecret = false;
|
|
212
|
+
let redactNextHeader = false;
|
|
213
|
+
return value.map((item) => {
|
|
214
|
+
if (redactNextSecret || redactNextHeader) {
|
|
215
|
+
redactNextSecret = false;
|
|
216
|
+
redactNextHeader = false;
|
|
217
|
+
return '<redacted>';
|
|
218
|
+
}
|
|
219
|
+
if (typeof item === 'string' && /^--?(?:api[-_]?key|password|passwd|secret|token|private[-_]?key|credential|access[-_]?key|client[-_]?secret|passphrase)$/i.test(item)) {
|
|
220
|
+
redactNextSecret = true;
|
|
221
|
+
}
|
|
222
|
+
else if (typeof item === 'string' && /^(?:-H|--header)$/i.test(item)) {
|
|
223
|
+
redactNextHeader = true;
|
|
224
|
+
}
|
|
225
|
+
return sanitizeForError(item, key);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (isObject(value)) {
|
|
229
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, sanitizeForError(childValue, childKey)]));
|
|
230
|
+
}
|
|
231
|
+
if (typeof value !== 'string')
|
|
232
|
+
return value;
|
|
233
|
+
const sanitizedHeader = sanitizeHeaderArgument(value);
|
|
234
|
+
if (sanitizedHeader)
|
|
235
|
+
return sanitizedHeader;
|
|
236
|
+
if (/url|uri|endpoint/i.test(key))
|
|
237
|
+
return '<url>';
|
|
238
|
+
if (/\bBearer\s+\S+/i.test(value)
|
|
239
|
+
|| /--?(?:api[-_]?key|password|passwd|secret|token|private[-_]?key|credential|access[-_]?key|client[-_]?secret|passphrase)(?:=|\s+)\S+/i.test(value)) {
|
|
240
|
+
return '<redacted>';
|
|
241
|
+
}
|
|
242
|
+
if (/[a-z][a-z\d+.-]*:\/\/[^\s"'`<>]+/i.test(value)) {
|
|
243
|
+
return value.replace(/[a-z][a-z\d+.-]*:\/\/[^\s"'`<>]+/gi, '<url>');
|
|
244
|
+
}
|
|
245
|
+
if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /=(?:\/|[A-Za-z]:[\\/])/.test(value))
|
|
246
|
+
return '<absolute-path>';
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
function isSensitiveName(name) {
|
|
250
|
+
const normalized = name.replace(/[^a-z\d]/gi, '').toLowerCase();
|
|
251
|
+
return normalized === 'sig'
|
|
252
|
+
|| normalized === 'auth'
|
|
253
|
+
|| ['token', 'secret', 'password', 'passwd', 'passphrase', 'credential', 'signature', 'authorization',
|
|
254
|
+
'apikey', 'accesskey', 'privatekey', 'clientsecret', 'cookie', 'setcookie']
|
|
255
|
+
.some((fragment) => normalized.includes(fragment));
|
|
256
|
+
}
|
|
257
|
+
function isSensitiveQueryName(name) {
|
|
258
|
+
const normalized = name.replace(/[^a-z\d]/gi, '').toLowerCase();
|
|
259
|
+
return normalized === 'key'
|
|
260
|
+
|| normalized === 'sig'
|
|
261
|
+
|| normalized === 'auth'
|
|
262
|
+
|| normalized === 'code'
|
|
263
|
+
|| ['token', 'secret', 'password', 'passwd', 'passphrase', 'credential', 'signature', 'authorization',
|
|
264
|
+
'apikey', 'accesskey', 'privatekey', 'clientsecret', 'cookie', 'setcookie']
|
|
265
|
+
.some((suffix) => normalized.endsWith(suffix));
|
|
266
|
+
}
|
|
267
|
+
function statIfExists(path) {
|
|
268
|
+
try {
|
|
269
|
+
return lstatSync(path);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error.code === 'ENOENT')
|
|
273
|
+
return undefined;
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function assertSafePath(path, label) {
|
|
278
|
+
const stat = statIfExists(path);
|
|
279
|
+
if (stat?.isSymbolicLink())
|
|
280
|
+
throw new SymlinkRefusedError(label, path);
|
|
281
|
+
}
|
|
282
|
+
function assertSafeParents(runtime, configRoot, path) {
|
|
283
|
+
const resolvedRoot = resolve(configRoot);
|
|
284
|
+
const resolvedPath = resolve(path);
|
|
285
|
+
const relativePath = relative(resolvedRoot, resolvedPath);
|
|
286
|
+
if (relativePath === ''
|
|
287
|
+
|| isAbsolute(relativePath)
|
|
288
|
+
|| relativePath === '..'
|
|
289
|
+
|| relativePath.startsWith(`..${sep}`)) {
|
|
290
|
+
throw new McpConfigOwnershipError(runtime, 'configuration path is not contained by its safe root');
|
|
291
|
+
}
|
|
292
|
+
assertSafePath(resolvedRoot, 'config root');
|
|
293
|
+
let current = resolvedRoot;
|
|
294
|
+
for (const segment of relativePath.split(sep).slice(0, -1)) {
|
|
295
|
+
current = join(current, segment);
|
|
296
|
+
assertSafePath(current, 'runtime config directory');
|
|
297
|
+
}
|
|
298
|
+
assertSafePath(resolvedPath, 'runtime config file');
|
|
299
|
+
assertSafePath(backupPath(resolvedPath), 'runtime config backup');
|
|
300
|
+
}
|
|
301
|
+
function candidateIsActiveOrConfigured(spec, candidate, activeConfigPath) {
|
|
302
|
+
return (resolve(candidate) === resolve(activeConfigPath) || candidateContainsEvolver(spec, candidate));
|
|
303
|
+
}
|
|
304
|
+
function readSnapshot(runtime, path) {
|
|
305
|
+
if (!existsSync(path))
|
|
306
|
+
return { data: {}, raw: null, mode: CONFIG_MODE };
|
|
307
|
+
const raw = readFileSync(path, 'utf8');
|
|
308
|
+
if (!raw.trim())
|
|
309
|
+
throw new EmptySharedConfigError(`${runtime} MCP config`, path, runtime);
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(raw);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw new UnparseableConfigError(`${runtime} MCP config`, path, runtime);
|
|
316
|
+
}
|
|
317
|
+
if (!isObject(parsed))
|
|
318
|
+
throw new McpConfigShapeError(runtime, path, 'the top-level JSON value must be an object');
|
|
319
|
+
return { data: parsed, raw, mode: statSync(path).mode & 0o777 };
|
|
320
|
+
}
|
|
321
|
+
function readRawIfExists(path) {
|
|
322
|
+
try {
|
|
323
|
+
return readFileSync(path, 'utf8');
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error.code === 'ENOENT')
|
|
327
|
+
return null;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function assertUnchanged(runtime, path, expectedRaw) {
|
|
332
|
+
assertSafePath(path, 'runtime config file');
|
|
333
|
+
if (readRawIfExists(path) !== expectedRaw)
|
|
334
|
+
throw new McpConfigChangedError(runtime, path);
|
|
335
|
+
}
|
|
336
|
+
function captureConfigTopology(runtime, resolution) {
|
|
337
|
+
const paths = [...new Set(resolution.topologyCandidatePaths ?? [])];
|
|
338
|
+
const candidates = paths.map((path) => {
|
|
339
|
+
assertSafeParents(runtime, resolution.safeRoot, path);
|
|
340
|
+
const raw = readRawIfExists(path);
|
|
341
|
+
assertSafeParents(runtime, resolution.safeRoot, path);
|
|
342
|
+
return { path, raw };
|
|
343
|
+
});
|
|
344
|
+
if (candidates.length > 0 && !resolution.managedRetarget) {
|
|
345
|
+
const observedActive = [...candidates].reverse().find((candidate) => candidate.raw !== null)?.path
|
|
346
|
+
?? candidates[0].path;
|
|
347
|
+
if (resolve(observedActive) !== resolve(resolution.configPath)) {
|
|
348
|
+
throw new McpConfigChangedError(runtime, observedActive);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
candidates: candidates.filter((candidate) => resolve(candidate.path) !== resolve(resolution.configPath)),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function assertConfigTopologyUnchanged(runtime, safeRoot, snapshot) {
|
|
356
|
+
for (const candidate of snapshot.candidates) {
|
|
357
|
+
assertSafeParents(runtime, safeRoot, candidate.path);
|
|
358
|
+
const raw = readRawIfExists(candidate.path);
|
|
359
|
+
assertSafeParents(runtime, safeRoot, candidate.path);
|
|
360
|
+
if (raw !== candidate.raw)
|
|
361
|
+
throw new McpConfigChangedError(runtime, candidate.path);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function atomicWrite(path, raw, mode, beforeRename) {
|
|
365
|
+
mkdirSync(dirname(path), { recursive: true, mode: DIR_MODE });
|
|
366
|
+
const tempPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
367
|
+
try {
|
|
368
|
+
writeFileSync(tempPath, raw, { encoding: 'utf8', mode, flag: 'wx' });
|
|
369
|
+
chmodSync(tempPath, mode);
|
|
370
|
+
beforeRename?.();
|
|
371
|
+
renameSync(tempPath, path);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
rmSync(tempPath, { force: true });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function guardedAtomicWrite(runtime, safeRoot, path, raw, mode, expectedRaw, additionalGuard) {
|
|
378
|
+
atomicWrite(path, raw, mode, () => {
|
|
379
|
+
beforeReplaceHookForTest?.(path);
|
|
380
|
+
assertSafeParents(runtime, safeRoot, path);
|
|
381
|
+
assertUnchanged(runtime, path, expectedRaw);
|
|
382
|
+
additionalGuard?.();
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function guardedRemove(runtime, safeRoot, path, expectedRaw, additionalGuard) {
|
|
386
|
+
beforeReplaceHookForTest?.(path);
|
|
387
|
+
assertSafeParents(runtime, safeRoot, path);
|
|
388
|
+
assertUnchanged(runtime, path, expectedRaw);
|
|
389
|
+
additionalGuard?.();
|
|
390
|
+
rmSync(path);
|
|
391
|
+
}
|
|
392
|
+
function backupPath(configPath) {
|
|
393
|
+
return `${configPath}.evolver-backup.json`;
|
|
394
|
+
}
|
|
395
|
+
function backupRaw(record) {
|
|
396
|
+
return `${JSON.stringify(record, null, 2)}\n`;
|
|
397
|
+
}
|
|
398
|
+
function createBackup(configPath, originalRaw, installedRaw, installedEntry) {
|
|
399
|
+
const path = backupPath(configPath);
|
|
400
|
+
const record = {
|
|
401
|
+
version: BACKUP_VERSION,
|
|
402
|
+
originalExists: originalRaw !== null,
|
|
403
|
+
originalRaw,
|
|
404
|
+
installedDigest: digest(installedRaw),
|
|
405
|
+
installedEntry,
|
|
406
|
+
};
|
|
407
|
+
const raw = backupRaw(record);
|
|
408
|
+
mkdirSync(dirname(path), { recursive: true, mode: DIR_MODE });
|
|
409
|
+
const tempPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
410
|
+
try {
|
|
411
|
+
writeFileSync(tempPath, raw, { encoding: 'utf8', mode: CONFIG_MODE, flag: 'wx' });
|
|
412
|
+
chmodSync(tempPath, CONFIG_MODE);
|
|
413
|
+
linkSync(tempPath, path);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error.code === 'EEXIST') {
|
|
417
|
+
throw new McpConfigChangedError('Evolver backup', path);
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
rmSync(tempPath, { force: true });
|
|
423
|
+
}
|
|
424
|
+
return { record, raw };
|
|
425
|
+
}
|
|
426
|
+
function replaceBackup(configPath, current, nextRecord) {
|
|
427
|
+
const path = backupPath(configPath);
|
|
428
|
+
const raw = backupRaw(nextRecord);
|
|
429
|
+
guardedAtomicWrite('Evolver backup', dirname(path), path, raw, CONFIG_MODE, current.raw);
|
|
430
|
+
return { record: nextRecord, raw };
|
|
431
|
+
}
|
|
432
|
+
function assertBackupUnchanged(configPath, expectedRaw) {
|
|
433
|
+
const path = backupPath(configPath);
|
|
434
|
+
assertSafePath(path, 'runtime config backup');
|
|
435
|
+
if (readRawIfExists(path) !== expectedRaw)
|
|
436
|
+
throw new McpConfigChangedError('Evolver backup', path);
|
|
437
|
+
}
|
|
438
|
+
function removeBackupIfUnchanged(configPath, expectedRaw) {
|
|
439
|
+
const path = backupPath(configPath);
|
|
440
|
+
assertSafePath(path, 'runtime config backup');
|
|
441
|
+
if (readRawIfExists(path) === expectedRaw)
|
|
442
|
+
rmSync(path);
|
|
443
|
+
}
|
|
444
|
+
function readBackup(configPath) {
|
|
445
|
+
const path = backupPath(configPath);
|
|
446
|
+
if (!existsSync(path))
|
|
447
|
+
return undefined;
|
|
448
|
+
const raw = readFileSync(path, 'utf8');
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(raw);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
throw new McpConfigShapeError('Evolver backup', path, 'backup metadata is not valid JSON');
|
|
455
|
+
}
|
|
456
|
+
if (!isObject(parsed) || parsed['version'] !== BACKUP_VERSION || typeof parsed['originalExists'] !== 'boolean'
|
|
457
|
+
|| (parsed['originalRaw'] !== null && typeof parsed['originalRaw'] !== 'string')
|
|
458
|
+
|| typeof parsed['installedDigest'] !== 'string' || !/^[a-f\d]{64}$/.test(parsed['installedDigest'])
|
|
459
|
+
|| (Object.prototype.hasOwnProperty.call(parsed, 'installedEntry') && !isObject(parsed['installedEntry']))
|
|
460
|
+
|| (parsed['originalExists'] !== (parsed['originalRaw'] !== null))) {
|
|
461
|
+
throw new McpConfigShapeError('Evolver backup', path, 'invalid backup metadata');
|
|
462
|
+
}
|
|
463
|
+
return { record: parsed, raw };
|
|
464
|
+
}
|
|
465
|
+
function parseBackupOriginal(runtime, configPath, backup) {
|
|
466
|
+
if (backup.originalRaw === null)
|
|
467
|
+
return {};
|
|
468
|
+
let parsed;
|
|
469
|
+
try {
|
|
470
|
+
parsed = JSON.parse(backup.originalRaw);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'original config is not valid JSON');
|
|
474
|
+
}
|
|
475
|
+
if (!isObject(parsed)) {
|
|
476
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'original config must be a JSON object');
|
|
477
|
+
}
|
|
478
|
+
return parsed;
|
|
479
|
+
}
|
|
480
|
+
function validateExistingBackupForInstall(spec, configPath, snapshot, expected, actual, force) {
|
|
481
|
+
const backup = readBackup(configPath);
|
|
482
|
+
if (!backup)
|
|
483
|
+
return undefined;
|
|
484
|
+
if (snapshot.raw === null || actual === undefined) {
|
|
485
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'stale backup exists without an installed Evolver entry');
|
|
486
|
+
}
|
|
487
|
+
const original = parseBackupOriginal(spec.runtime, configPath, backup.record);
|
|
488
|
+
const originalContainer = validateContainer(spec.runtime, backupPath(configPath), original, spec.containerKey);
|
|
489
|
+
const hasInstalledEntry = Object.prototype.hasOwnProperty.call(backup.record, 'installedEntry');
|
|
490
|
+
const currentDigestMatches = digest(snapshot.raw) === backup.record.installedDigest;
|
|
491
|
+
if ('evolver' in originalContainer && !hasInstalledEntry) {
|
|
492
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'an original Evolver entry requires managed-entry metadata');
|
|
493
|
+
}
|
|
494
|
+
if (currentDigestMatches && hasInstalledEntry && !stableEqual(actual, backup.record.installedEntry)) {
|
|
495
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'installed entry does not match the current configuration');
|
|
496
|
+
}
|
|
497
|
+
if (force && actual !== undefined)
|
|
498
|
+
return backup;
|
|
499
|
+
const unrelatedConfigMatches = stableEqual(withoutEvolver(snapshot.data, spec.containerKey), withoutEvolver(original, spec.containerKey));
|
|
500
|
+
if (!currentDigestMatches) {
|
|
501
|
+
if (!unrelatedConfigMatches && !stableEqual(actual, expected)) {
|
|
502
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'stale backup does not match the current Evolver installation');
|
|
503
|
+
}
|
|
504
|
+
return backup;
|
|
505
|
+
}
|
|
506
|
+
if (!unrelatedConfigMatches) {
|
|
507
|
+
throw new McpConfigShapeError('Evolver backup', backupPath(configPath), 'backup contents do not match the installed configuration');
|
|
508
|
+
}
|
|
509
|
+
return backup;
|
|
510
|
+
}
|
|
511
|
+
function validateContainer(runtime, path, data, key) {
|
|
512
|
+
const value = data[key];
|
|
513
|
+
if (value === undefined)
|
|
514
|
+
return {};
|
|
515
|
+
if (!isObject(value))
|
|
516
|
+
throw new McpConfigShapeError(runtime, path, `${key} must be a JSON object`);
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
function validateServer(server) {
|
|
520
|
+
if (!server.command.trim())
|
|
521
|
+
throw new McpServerValidationError('MCP server command must not be empty');
|
|
522
|
+
const commandLine = [server.command, ...(server.args ?? [])].join(' ');
|
|
523
|
+
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i.test(commandLine)
|
|
524
|
+
|| /(?:^|\s)--?(?:api[-_]?key|password|passwd|secret|token|private[-_]?key|credential|access[-_]?key|client[-_]?secret|passphrase)(?:=|\s+)\S+/i.test(commandLine)) {
|
|
525
|
+
throw new McpServerValidationError('refusing inline secret-looking MCP command arguments; use EVOLVER_ENV_FILE');
|
|
526
|
+
}
|
|
527
|
+
for (const arg of server.args ?? []) {
|
|
528
|
+
for (const match of arg.matchAll(/[a-z][a-z\d+.-]*:\/\/[^\s"'`<>]+/gi)) {
|
|
529
|
+
let url;
|
|
530
|
+
try {
|
|
531
|
+
url = new URL(match[0]);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (url.username || url.password || [...url.searchParams.keys()].some(isSensitiveQueryName)) {
|
|
537
|
+
throw new McpServerValidationError('refusing credential-bearing MCP server URL arguments; use EVOLVER_ENV_FILE');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const envKeys = Object.keys(server.env ?? {});
|
|
542
|
+
if (envKeys.some((key) => key !== ENV_FILE_KEY)) {
|
|
543
|
+
throw new McpServerValidationError(`refusing inline MCP environment values; only ${ENV_FILE_KEY} is allowed`);
|
|
544
|
+
}
|
|
545
|
+
const envFile = server.env?.[ENV_FILE_KEY]?.trim();
|
|
546
|
+
if (envKeys.length > 0 && (!envFile || /[\0\r\n]/.test(envFile) || !looksLikeEnvFilePointer(envFile))) {
|
|
547
|
+
throw new McpServerValidationError(`${ENV_FILE_KEY} must contain a file path, not a credential value`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function looksLikeEnvFilePointer(value) {
|
|
551
|
+
return /[\\/]/.test(value)
|
|
552
|
+
|| /^[.~$%]/.test(value)
|
|
553
|
+
|| /\.(?:env|dotenv)(?:$|[._-])/i.test(value);
|
|
554
|
+
}
|
|
555
|
+
export function installJsonMcpRuntime(spec, plan, opts) {
|
|
556
|
+
validateServer(opts.server);
|
|
557
|
+
const resolution = resolveInstallRuntimeConfig(spec, opts);
|
|
558
|
+
const { configPath, safeRoot } = resolution;
|
|
559
|
+
assertSafeParents(spec.runtime, safeRoot, configPath);
|
|
560
|
+
const topologySnapshot = captureConfigTopology(spec.runtime, resolution);
|
|
561
|
+
for (const conflictingPath of resolution.conflictingPaths) {
|
|
562
|
+
assertSafeParents(spec.runtime, safeRoot, conflictingPath);
|
|
563
|
+
throw new McpConfigConflictError(spec.runtime, conflictingPath, 'a writable JSON config at the active precedence', 'JSONC config would override the Evolver-managed JSON target');
|
|
564
|
+
}
|
|
565
|
+
const expected = spec.entry(opts.server);
|
|
566
|
+
const preflight = spec.installPreflight?.(resolution, opts, expected);
|
|
567
|
+
if (preflight?.alreadyInstalled) {
|
|
568
|
+
preflight.assertUnchanged();
|
|
569
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
runtime: plan.runtime,
|
|
573
|
+
mode: plan.mode,
|
|
574
|
+
files: [],
|
|
575
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
576
|
+
alreadyInstalled: true,
|
|
577
|
+
verified: true,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const snapshot = readSnapshot(spec.runtime, configPath);
|
|
581
|
+
const container = validateContainer(spec.runtime, configPath, snapshot.data, spec.containerKey);
|
|
582
|
+
const actual = container['evolver'];
|
|
583
|
+
const existingBackup = validateExistingBackupForInstall(spec, configPath, snapshot, expected, actual, opts.force === true);
|
|
584
|
+
if (resolution.activePrecedencePath) {
|
|
585
|
+
const activePath = resolution.activePrecedencePath;
|
|
586
|
+
assertSafeParents(spec.runtime, safeRoot, activePath);
|
|
587
|
+
const activeSnapshot = readSnapshot(spec.runtime, activePath);
|
|
588
|
+
const activeContainer = validateContainer(spec.runtime, activePath, activeSnapshot.data, spec.containerKey);
|
|
589
|
+
const activeActual = activeContainer['evolver'];
|
|
590
|
+
if (stableEqual(activeActual, expected)) {
|
|
591
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
runtime: plan.runtime,
|
|
595
|
+
mode: plan.mode,
|
|
596
|
+
files: [],
|
|
597
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
598
|
+
alreadyInstalled: true,
|
|
599
|
+
verified: true,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
throw new McpConfigConflictError(spec.runtime, `${spec.containerKey}.evolver`, expected, activeActual === undefined ? '<missing>' : activeActual);
|
|
603
|
+
}
|
|
604
|
+
if (actual !== undefined && !stableEqual(actual, expected) && !opts.force) {
|
|
605
|
+
throw new McpConfigConflictError(spec.runtime, `${spec.containerKey}.evolver`, expected, actual);
|
|
606
|
+
}
|
|
607
|
+
if (actual !== undefined && stableEqual(actual, expected)) {
|
|
608
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
609
|
+
return {
|
|
610
|
+
ok: true,
|
|
611
|
+
runtime: plan.runtime,
|
|
612
|
+
mode: plan.mode,
|
|
613
|
+
files: [],
|
|
614
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
615
|
+
alreadyInstalled: true,
|
|
616
|
+
verified: true,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const installed = { ...snapshot.data, [spec.containerKey]: { ...container, evolver: expected } };
|
|
620
|
+
const installedRaw = `${JSON.stringify(installed, null, 2)}\n`;
|
|
621
|
+
const backup = backupPath(configPath);
|
|
622
|
+
if (opts.dryRun) {
|
|
623
|
+
preflight?.assertUnchanged();
|
|
624
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
625
|
+
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [configPath], backups: [backup], dryRun: true };
|
|
626
|
+
}
|
|
627
|
+
const backupExisted = existingBackup !== undefined;
|
|
628
|
+
let activeBackup = existingBackup;
|
|
629
|
+
const previousBackup = existingBackup;
|
|
630
|
+
let configReplaced = false;
|
|
631
|
+
try {
|
|
632
|
+
preflight?.assertUnchanged();
|
|
633
|
+
if (!activeBackup) {
|
|
634
|
+
activeBackup = createBackup(configPath, snapshot.raw, installedRaw, expected);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const currentWasUnmodified = snapshot.raw !== null && digest(snapshot.raw) === activeBackup.record.installedDigest;
|
|
638
|
+
const nextRecord = {
|
|
639
|
+
...activeBackup.record,
|
|
640
|
+
...(currentWasUnmodified ? { installedDigest: digest(installedRaw) } : {}),
|
|
641
|
+
installedEntry: expected,
|
|
642
|
+
};
|
|
643
|
+
if (!stableEqual(nextRecord, activeBackup.record)) {
|
|
644
|
+
activeBackup = replaceBackup(configPath, activeBackup, nextRecord);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const expectedBackupRaw = activeBackup.raw;
|
|
648
|
+
guardedAtomicWrite(spec.runtime, safeRoot, configPath, installedRaw, snapshot.mode, snapshot.raw, () => {
|
|
649
|
+
preflight?.assertUnchanged();
|
|
650
|
+
assertBackupUnchanged(configPath, expectedBackupRaw);
|
|
651
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
652
|
+
});
|
|
653
|
+
configReplaced = true;
|
|
654
|
+
afterReplaceHookForTest?.(configPath);
|
|
655
|
+
preflight?.assertUnchanged();
|
|
656
|
+
const verified = readSnapshot(spec.runtime, configPath).data;
|
|
657
|
+
const verifiedContainer = validateContainer(spec.runtime, configPath, verified, spec.containerKey);
|
|
658
|
+
if (!stableEqual(verifiedContainer['evolver'], expected))
|
|
659
|
+
throw new McpConfigVerificationError(spec.runtime, configPath);
|
|
660
|
+
assertBackupUnchanged(configPath, expectedBackupRaw);
|
|
661
|
+
assertConfigTopologyUnchanged(spec.runtime, safeRoot, topologySnapshot);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
const currentRaw = readRawIfExists(configPath);
|
|
665
|
+
const installReachedConfig = currentRaw === installedRaw;
|
|
666
|
+
let restored = currentRaw === snapshot.raw;
|
|
667
|
+
if (installReachedConfig) {
|
|
668
|
+
try {
|
|
669
|
+
if (snapshot.raw === null)
|
|
670
|
+
guardedRemove(spec.runtime, safeRoot, configPath, installedRaw);
|
|
671
|
+
else
|
|
672
|
+
guardedAtomicWrite(spec.runtime, safeRoot, configPath, snapshot.raw, snapshot.mode, installedRaw);
|
|
673
|
+
restored = readRawIfExists(configPath) === snapshot.raw;
|
|
674
|
+
}
|
|
675
|
+
catch (rollbackError) {
|
|
676
|
+
error.rollbackError = rollbackError;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (error instanceof McpConfigVerificationError && restored)
|
|
680
|
+
error.markRestored();
|
|
681
|
+
if (!configReplaced || restored) {
|
|
682
|
+
if (!backupExisted && activeBackup) {
|
|
683
|
+
removeBackupIfUnchanged(configPath, activeBackup.raw);
|
|
684
|
+
}
|
|
685
|
+
else if (previousBackup && activeBackup && activeBackup.raw !== previousBackup.raw) {
|
|
686
|
+
try {
|
|
687
|
+
guardedAtomicWrite('Evolver backup', dirname(backupPath(configPath)), backupPath(configPath), previousBackup.raw, CONFIG_MODE, activeBackup.raw);
|
|
688
|
+
}
|
|
689
|
+
catch (rollbackError) {
|
|
690
|
+
error.backupRollbackError = rollbackError;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [configPath], backups: [backup], verified: true };
|
|
697
|
+
}
|
|
698
|
+
function withoutEvolver(data, containerKey) {
|
|
699
|
+
const container = validateContainer('runtime', '<config>', data, containerKey);
|
|
700
|
+
const nextContainer = { ...container };
|
|
701
|
+
delete nextContainer['evolver'];
|
|
702
|
+
const next = { ...data };
|
|
703
|
+
if (Object.keys(nextContainer).length > 0)
|
|
704
|
+
next[containerKey] = nextContainer;
|
|
705
|
+
else
|
|
706
|
+
delete next[containerKey];
|
|
707
|
+
return next;
|
|
708
|
+
}
|
|
709
|
+
export function uninstallJsonMcpRuntime(spec, runtime, opts) {
|
|
710
|
+
const resolution = resolveUninstallRuntimeConfig(spec, opts);
|
|
711
|
+
const { configPath, safeRoot } = resolution;
|
|
712
|
+
assertSafeParents(spec.runtime, safeRoot, configPath);
|
|
713
|
+
for (const conflictingPath of resolution.conflictingPaths) {
|
|
714
|
+
assertSafeParents(spec.runtime, safeRoot, conflictingPath);
|
|
715
|
+
throw new McpConfigConflictError(spec.runtime, conflictingPath, 'a writable JSON config at the active precedence', 'JSONC config would override the Evolver-managed JSON target');
|
|
716
|
+
}
|
|
717
|
+
const backup = readBackup(configPath);
|
|
718
|
+
const backupFile = backupPath(configPath);
|
|
719
|
+
if (!existsSync(configPath)) {
|
|
720
|
+
if (!backup)
|
|
721
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], verified: true };
|
|
722
|
+
if (opts.dryRun) {
|
|
723
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], backups: [backupFile], dryRun: true, verified: true };
|
|
724
|
+
}
|
|
725
|
+
assertBackupUnchanged(configPath, backup.raw);
|
|
726
|
+
removeBackup(backupFile);
|
|
727
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], backups: [backupFile], verified: true };
|
|
728
|
+
}
|
|
729
|
+
const snapshot = readSnapshot(spec.runtime, configPath);
|
|
730
|
+
const container = validateContainer(spec.runtime, configPath, snapshot.data, spec.containerKey);
|
|
731
|
+
if (!('evolver' in container)) {
|
|
732
|
+
if (!backup)
|
|
733
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], verified: true };
|
|
734
|
+
if (opts.dryRun) {
|
|
735
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], backups: [backupFile], dryRun: true, verified: true };
|
|
736
|
+
}
|
|
737
|
+
assertBackupUnchanged(configPath, backup.raw);
|
|
738
|
+
removeBackup(backupFile);
|
|
739
|
+
return { ok: true, runtime, mode: 'uninstall', files: [], backups: [backupFile], verified: true };
|
|
740
|
+
}
|
|
741
|
+
const currentRaw = snapshot.raw;
|
|
742
|
+
const backupGuard = backup ? () => assertBackupUnchanged(configPath, backup.raw) : undefined;
|
|
743
|
+
let nextRaw = snapshot.raw;
|
|
744
|
+
if (backup && digest(currentRaw) === backup.record.installedDigest) {
|
|
745
|
+
const original = parseBackupOriginal(spec.runtime, configPath, backup.record);
|
|
746
|
+
if (!stableEqual(withoutEvolver(snapshot.data, spec.containerKey), withoutEvolver(original, spec.containerKey))) {
|
|
747
|
+
throw new McpConfigOwnershipError(spec.runtime, 'backup contents do not match the installed configuration');
|
|
748
|
+
}
|
|
749
|
+
nextRaw = backup.record.originalExists ? backup.record.originalRaw : null;
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
if (!backup) {
|
|
753
|
+
throw new McpConfigOwnershipError(spec.runtime, 'an Evolver entry exists without a managed backup; ownership cannot be proven');
|
|
754
|
+
}
|
|
755
|
+
if (!Object.prototype.hasOwnProperty.call(backup.record, 'installedEntry')) {
|
|
756
|
+
throw new McpConfigOwnershipError(spec.runtime, 'legacy backup metadata can only be restored when the installed configuration matches exactly');
|
|
757
|
+
}
|
|
758
|
+
if (!stableEqual(container['evolver'], backup.record.installedEntry)) {
|
|
759
|
+
throw new McpConfigOwnershipError(spec.runtime, 'the managed Evolver entry was changed after installation; refusing to remove user changes');
|
|
760
|
+
}
|
|
761
|
+
const nextContainer = { ...container };
|
|
762
|
+
const original = parseBackupOriginal(spec.runtime, configPath, backup.record);
|
|
763
|
+
const originalContainer = validateContainer(spec.runtime, backupPath(configPath), original, spec.containerKey);
|
|
764
|
+
if ('evolver' in originalContainer) {
|
|
765
|
+
nextContainer['evolver'] = originalContainer['evolver'];
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
delete nextContainer['evolver'];
|
|
769
|
+
}
|
|
770
|
+
const next = { ...snapshot.data };
|
|
771
|
+
if (Object.keys(nextContainer).length > 0)
|
|
772
|
+
next[spec.containerKey] = nextContainer;
|
|
773
|
+
else
|
|
774
|
+
delete next[spec.containerKey];
|
|
775
|
+
nextRaw = `${JSON.stringify(next, null, 2)}\n`;
|
|
776
|
+
}
|
|
777
|
+
if (opts.dryRun) {
|
|
778
|
+
return {
|
|
779
|
+
ok: true,
|
|
780
|
+
runtime,
|
|
781
|
+
mode: 'uninstall',
|
|
782
|
+
files: [configPath],
|
|
783
|
+
backups: backup ? [backupFile] : [],
|
|
784
|
+
dryRun: true,
|
|
785
|
+
verified: true,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
let configChanged = false;
|
|
789
|
+
try {
|
|
790
|
+
if (nextRaw === null) {
|
|
791
|
+
guardedRemove(spec.runtime, safeRoot, configPath, snapshot.raw, backupGuard);
|
|
792
|
+
configChanged = true;
|
|
793
|
+
}
|
|
794
|
+
else if (nextRaw !== snapshot.raw) {
|
|
795
|
+
guardedAtomicWrite(spec.runtime, safeRoot, configPath, nextRaw, snapshot.mode, snapshot.raw, backupGuard);
|
|
796
|
+
configChanged = true;
|
|
797
|
+
}
|
|
798
|
+
if (readRawIfExists(configPath) !== nextRaw) {
|
|
799
|
+
throw new McpConfigVerificationError(spec.runtime, configPath);
|
|
800
|
+
}
|
|
801
|
+
if (backup) {
|
|
802
|
+
assertSafePath(backupFile, 'runtime config backup');
|
|
803
|
+
if (readRawIfExists(backupFile) !== backup.raw) {
|
|
804
|
+
throw new McpConfigChangedError('Evolver backup', backupFile);
|
|
805
|
+
}
|
|
806
|
+
removeBackup(backupFile);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
if (configChanged && readRawIfExists(configPath) === nextRaw) {
|
|
811
|
+
try {
|
|
812
|
+
guardedAtomicWrite(spec.runtime, safeRoot, configPath, snapshot.raw, snapshot.mode, nextRaw, backupGuard);
|
|
813
|
+
if (readRawIfExists(configPath) !== snapshot.raw) {
|
|
814
|
+
throw new McpConfigVerificationError(spec.runtime, configPath);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (rollbackError) {
|
|
818
|
+
error.rollbackError = rollbackError;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
return { ok: true, runtime, mode: 'uninstall', files: [configPath], backups: backup ? [backupFile] : [], verified: true };
|
|
824
|
+
}
|