mcp-pacemaker 1.3.1 → 2.0.2

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.
@@ -0,0 +1,541 @@
1
+ import fs from 'node:fs';
2
+ import childProcess from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { dirname, extname, basename, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { PoolingConfigError, poolingIOError, poolingConflict, recoveryError } from './pooling-errors.mjs';
7
+ import { JsonSpans } from './pooling-editor.mjs';
8
+ import { traceErrorCode } from './pooling-trace.mjs';
9
+
10
+ export const MAX_CONFIG_BYTES = 1024 * 1024;
11
+ export const MAX_TRANSACTION_BYTES = 16384;
12
+ const HELPER = fileURLToPath(new URL('./windows/PoolingSecurityHelper.exe', import.meta.url));
13
+ const HEX = '0123456789abcdef';
14
+
15
+ export function hashBytes(bytes) {
16
+ return createHash('sha256').update(bytes).digest('hex');
17
+ }
18
+
19
+ function hex(value, length) {
20
+ return typeof value === 'string' &&
21
+ value.length === length &&
22
+ [...value].every((character) => HEX.includes(character));
23
+ }
24
+
25
+ function exactKeys(value, keys) {
26
+ return value !== null &&
27
+ typeof value === 'object' &&
28
+ !Array.isArray(value) &&
29
+ Object.keys(value).sort().join(',') === [...keys].sort().join(',');
30
+ }
31
+
32
+ function validDescriptor(value) {
33
+ if (!exactKeys(value, ['identity', 'revision', 'security', 'size']) ||
34
+ !hex(value.revision, 64) ||
35
+ !hex(value.security, 64) ||
36
+ !Number.isSafeInteger(value.size) ||
37
+ value.size < 0 ||
38
+ value.size > MAX_CONFIG_BYTES ||
39
+ typeof value.identity !== 'string') return false;
40
+ const parts = value.identity.split(':');
41
+ return parts.length === 2 &&
42
+ parts.every((part) => part.length > 0 &&
43
+ part.length <= 32 &&
44
+ [...part].every((character) => HEX.includes(character)));
45
+ }
46
+
47
+ function expected(descriptor) {
48
+ return { identity: descriptor.identity, revision: descriptor.revision, security: descriptor.security };
49
+ }
50
+
51
+ function same(a, b) {
52
+ return Boolean(a && b &&
53
+ a.identity === b.identity &&
54
+ a.revision === b.revision &&
55
+ a.security === b.security &&
56
+ a.size === b.size);
57
+ }
58
+
59
+ function exists(path) {
60
+ try {
61
+ fs.lstatSync(path);
62
+ return true;
63
+ } catch (error) {
64
+ if (error.code === 'ENOENT') return false;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ function boundedRead(path, limit, links = 1) {
70
+ let fd;
71
+ try {
72
+ const before = fs.lstatSync(path, { bigint: true });
73
+ if (!before.isFile() ||
74
+ before.isSymbolicLink() ||
75
+ before.nlink < 1n ||
76
+ before.nlink > BigInt(links) ||
77
+ before.size > BigInt(limit)) {
78
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular, bounded file without links.');
79
+ }
80
+ fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
81
+ const stat = fs.fstatSync(fd, { bigint: true });
82
+ if (before.dev !== stat.dev ||
83
+ before.ino !== stat.ino ||
84
+ before.size !== stat.size) throw poolingConflict();
85
+ const buffer = Buffer.alloc(limit + 1);
86
+ let size = 0;
87
+ while (size < buffer.length) {
88
+ const count = fs.readSync(fd, buffer, size, buffer.length - size, null);
89
+ if (!count) break;
90
+ size += count;
91
+ }
92
+ if (size > limit) {
93
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config exceeds its size limit.');
94
+ }
95
+ const after = fs.fstatSync(fd, { bigint: true });
96
+ if (after.size !== stat.size ||
97
+ after.mtimeNs !== stat.mtimeNs ||
98
+ after.ctimeNs !== stat.ctimeNs) throw poolingConflict();
99
+ return { bytes: buffer.subarray(0, size), stat };
100
+ } finally {
101
+ if (fd !== undefined) fs.closeSync(fd);
102
+ }
103
+ }
104
+
105
+ export function readPoolingBytes(path) {
106
+ try {
107
+ const result = boundedRead(path, MAX_CONFIG_BYTES);
108
+ return { ...result, revision: hashBytes(result.bytes) };
109
+ } catch (error) {
110
+ if (error.code === 'ENOENT') {
111
+ throw new PoolingConfigError(404, 'CONFIG_NOT_FOUND', 'Config file does not exist.');
112
+ }
113
+ throw poolingIOError(error);
114
+ }
115
+ }
116
+
117
+ // The two data placements are not atomic. POSIX link+unlink provides exclusive
118
+ // destination creation and retains the inode. Recovery accounts for either
119
+ // intermediate two-link state. Directory fsync is used on POSIX; Windows uses
120
+ // the helper's file flushes, not a power-loss guarantee for directory entries.
121
+ export class PoolingFiles {
122
+ constructor(configPath) {
123
+ this.active = resolve(configPath);
124
+ const extension = extname(this.active);
125
+ const stem = basename(this.active, extension);
126
+ const directory = dirname(this.active);
127
+ this.paths = {
128
+ pending: join(directory, `${stem}.pending${extension}`),
129
+ next: join(directory, `${stem}.pending-next${extension}`),
130
+ old: join(directory, `${stem}.pending-old${extension}`),
131
+ previous: join(directory, `${stem}.previous${extension}`),
132
+ };
133
+ this.directory = `${this.active}.pooling-transaction`;
134
+ this.lock = join(this.directory, 'owner');
135
+ this.record = null;
136
+ this.records = [];
137
+ this.owner = null;
138
+ this.poisoned = false;
139
+ }
140
+
141
+ syncDirectory(path = dirname(this.active)) {
142
+ if (process.platform === 'win32') return;
143
+ const fd = fs.openSync(path, 'r');
144
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
145
+ }
146
+
147
+ helper(action, source, destination, body, execution) {
148
+ execution?.check();
149
+ const options = {
150
+ shell: false,
151
+ windowsHide: true,
152
+ stdio: ['pipe', 'pipe', 'pipe'],
153
+ encoding: 'utf8',
154
+ input: body === undefined ? undefined : JSON.stringify(body),
155
+ maxBuffer: 16384,
156
+ timeout: Math.min(10000, execution?.remainingMs() ?? 10000),
157
+ env: { ...process.env, MCP_POOL_SOURCE: source, MCP_POOL_TEMP: destination ?? '' },
158
+ };
159
+ const trace = execution?.trace;
160
+ const call = trace?.helper();
161
+ trace?.record('helper-start', { action, call, timeoutMs: options.timeout });
162
+ const result = childProcess.spawnSync(HELPER, [action], options);
163
+ trace?.record('helper-end', {
164
+ action, call, helperPid: result.pid ?? 0, exit: result.status, code: traceErrorCode(result.error),
165
+ });
166
+ // The caller must inspect disk state after a late/failed move. Do not turn
167
+ // an entered commit into a supposedly cancelled operation here.
168
+ if (result.status === 3) throw poolingConflict();
169
+ if (result.error ||
170
+ result.status !== 0) {
171
+ execution?.check();
172
+ const lines = String(result.stderr ?? '').split('\n').map((line) => line.trim());
173
+ if (lines.includes('MCPERR nativeError=5')) {
174
+ throw new PoolingConfigError(403, 'ACCESS_DENIED', 'Config source write access or security preservation was refused.');
175
+ }
176
+ if (lines.includes('MCPERR nativeError=32')) {
177
+ throw new PoolingConfigError(409, 'FILE_BUSY', 'A config transaction file is in use.');
178
+ }
179
+ throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect, stage, or move a config transaction file.');
180
+ }
181
+ let descriptor;
182
+ try { descriptor = JSON.parse(result.stdout); } catch { throw poolingIOError(); }
183
+ if (!validDescriptor(descriptor)) throw poolingIOError();
184
+ return descriptor;
185
+ }
186
+
187
+ inspect(path, execution, links = 1) {
188
+ execution?.check();
189
+ if (process.platform === 'win32') return this.helper('inspect-access', path, undefined, undefined, execution);
190
+ const { bytes, stat } = boundedRead(path, MAX_CONFIG_BYTES, links);
191
+ return {
192
+ identity: `${stat.dev.toString(16)}:${stat.ino.toString(16)}`,
193
+ revision: hashBytes(bytes),
194
+ security: hashBytes(Buffer.from(`${stat.mode}:${stat.uid}:${stat.gid}`)),
195
+ size: bytes.length,
196
+ };
197
+ }
198
+
199
+ verify(path, descriptor, execution, links = 1) {
200
+ if (!same(this.inspect(path, execution, links), descriptor)) throw poolingConflict();
201
+ }
202
+
203
+ stage(path, source, descriptor, bytes, execution) {
204
+ execution?.check();
205
+ if (process.platform === 'win32') {
206
+ return this.helper('stage', source, path,
207
+ { expected: expected(descriptor), bytes: bytes.toString('base64') }, execution);
208
+ }
209
+ this.verify(source, descriptor, execution);
210
+ let input;
211
+ let output;
212
+ try {
213
+ input = fs.openSync(source, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0));
214
+ const stat = fs.fstatSync(input, { bigint: true });
215
+ if ((stat.mode & 0o222n) === 0n) {
216
+ throw new PoolingConfigError(403, 'ACCESS_DENIED', 'Config source write access was refused.');
217
+ }
218
+ const identity = `${stat.dev.toString(16)}:${stat.ino.toString(16)}`;
219
+ if (identity !== descriptor.identity) throw poolingConflict();
220
+ // 0600 prevents inherited POSIX ACL grants from exposing bytes. Owner,
221
+ // group and mode are set before writing. Extended ACL/label cloning is
222
+ // not provided by Node; copies never gain source-external mode grants.
223
+ output = fs.openSync(path, 'wx', 0o600);
224
+ const created = fs.fstatSync(output, { bigint: true });
225
+ if (created.uid !== stat.uid ||
226
+ created.gid !== stat.gid) fs.fchownSync(output, Number(stat.uid), Number(stat.gid));
227
+ // Keep private copies private rather than widening an inherited ACL mask.
228
+ fs.fchmodSync(output, Number(stat.mode & 0o600n));
229
+ this.verify(source, descriptor, execution);
230
+ execution?.check();
231
+ fs.writeFileSync(output, bytes);
232
+ fs.fsyncSync(output);
233
+ } finally {
234
+ if (output !== undefined) fs.closeSync(output);
235
+ if (input !== undefined) fs.closeSync(input);
236
+ }
237
+ this.syncDirectory();
238
+ const staged = this.inspect(path, execution);
239
+ if (staged.revision !== hashBytes(bytes)) throw poolingConflict();
240
+ this.verify(source, descriptor, execution);
241
+ return staged;
242
+ }
243
+
244
+ move(source, destination, descriptor, execution) {
245
+ execution?.check();
246
+ if (process.platform === 'win32') {
247
+ const moved = this.helper('move-no-replace', source, destination,
248
+ { expected: expected(descriptor) }, execution);
249
+ if (!same(moved, descriptor)) throw poolingConflict();
250
+ return;
251
+ }
252
+ this.verify(source, descriptor, execution);
253
+ fs.linkSync(source, destination);
254
+ this.syncDirectory();
255
+ this.verify(source, descriptor, execution, 2);
256
+ this.verify(destination, descriptor, execution, 2);
257
+ fs.unlinkSync(source);
258
+ this.syncDirectory();
259
+ this.verify(destination, descriptor, execution);
260
+ }
261
+
262
+ remove(path, descriptor, links = 1, execution) {
263
+ this.verify(path, descriptor, execution, links);
264
+ fs.unlinkSync(path);
265
+ this.syncDirectory();
266
+ }
267
+
268
+ writeMetadata(path, value) {
269
+ const bytes = Buffer.from(JSON.stringify(value));
270
+ if (bytes.length > MAX_TRANSACTION_BYTES) throw recoveryError();
271
+ const fd = fs.openSync(path, 'wx', 0o600);
272
+ try {
273
+ fs.writeFileSync(fd, bytes);
274
+ fs.fsyncSync(fd);
275
+ } finally { fs.closeSync(fd); }
276
+ this.syncDirectory(dirname(path));
277
+ return this.metadata(path);
278
+ }
279
+
280
+ metadata(path) {
281
+ const { bytes, stat } = boundedRead(path, MAX_TRANSACTION_BYTES);
282
+ let value;
283
+ try { value = JSON.parse(bytes.toString('utf8')); } catch { throw recoveryError(); }
284
+ // Records have one canonical encoding; this also rejects duplicate keys,
285
+ // malformed UTF-8 and trailing data without a second permissive parser.
286
+ if (!Buffer.from(JSON.stringify(value)).equals(bytes)) throw recoveryError();
287
+ return { value, hash: hashBytes(bytes), identity: `${stat.dev}:${stat.ino}`, path };
288
+ }
289
+
290
+ removeMetadata(record) {
291
+ const current = this.metadata(record.path);
292
+ if (current.hash !== record.hash ||
293
+ current.identity !== record.identity) throw recoveryError();
294
+ fs.unlinkSync(record.path);
295
+ this.syncDirectory(dirname(record.path));
296
+ }
297
+
298
+ validateOwner(record) {
299
+ const value = record.value;
300
+ if (!exactKeys(value, ['version', 'pid', 'id']) ||
301
+ value.version !== 1 ||
302
+ !Number.isSafeInteger(value.pid) ||
303
+ value.pid <= 0 ||
304
+ typeof value.id !== 'string' ||
305
+ value.id.length !== 36) throw recoveryError();
306
+ }
307
+
308
+ acquire() {
309
+ if (this.owner) {
310
+ const current = this.metadata(join(this.lock, 'process.json'));
311
+ if (current.hash !== this.owner.hash ||
312
+ current.identity !== this.owner.identity) throw recoveryError();
313
+ if (this.poisoned) throw recoveryError();
314
+ return;
315
+ }
316
+ if (!exists(this.directory)) {
317
+ try {
318
+ fs.mkdirSync(this.directory, { mode: 0o700 });
319
+ this.syncDirectory();
320
+ } catch (error) {
321
+ if (error.code !== 'EEXIST') throw error;
322
+ }
323
+ }
324
+ const stat = fs.lstatSync(this.directory);
325
+ if (!stat.isDirectory() ||
326
+ stat.isSymbolicLink()) throw recoveryError();
327
+ let acquired = false;
328
+ try {
329
+ fs.mkdirSync(this.lock, { mode: 0o700 });
330
+ acquired = true;
331
+ } catch (error) {
332
+ if (error.code !== 'EEXIST') throw error;
333
+ }
334
+ if (!acquired) {
335
+ const ownerStat = fs.lstatSync(this.lock);
336
+ if (!ownerStat.isDirectory() ||
337
+ ownerStat.isSymbolicLink()) throw recoveryError();
338
+ const owner = this.metadata(join(this.lock, 'process.json'));
339
+ this.validateOwner(owner);
340
+ try {
341
+ process.kill(owner.value.pid, 0);
342
+ throw new PoolingConfigError(409, 'STORE_BUSY', 'Another config transaction owner is still running.');
343
+ } catch (error) {
344
+ if (error.code !== 'ESRCH') throw error;
345
+ }
346
+ // Exclusive takeover gate: all normal entrants refuse an existing owner.
347
+ // A death inside takeover leaves a closed gate and requires inspection,
348
+ // rather than allowing two recovery processes to remove each other's lock.
349
+ try { fs.mkdirSync(join(this.lock, 'takeover')); } catch { throw recoveryError(); }
350
+ this.removeMetadata(owner);
351
+ }
352
+ this.owner = this.writeMetadata(join(this.lock, 'process.json'),
353
+ { version: 1, pid: process.pid, id: randomUUID() });
354
+ try {
355
+ if (!acquired) fs.rmdirSync(join(this.lock, 'takeover'));
356
+ this.syncDirectory(this.directory);
357
+ this.load();
358
+ } catch (error) {
359
+ this.poisoned = true;
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ load() {
365
+ const names = fs.readdirSync(this.directory);
366
+ const records = [];
367
+ if (names.length > 3) throw recoveryError();
368
+ for (const name of names) {
369
+ if (name === 'owner') continue;
370
+ if (!name.startsWith('state-') ||
371
+ !name.endsWith('.json')) throw recoveryError();
372
+ const record = this.metadata(join(this.directory, name));
373
+ const envelope = record.value;
374
+ if (!exactKeys(envelope, ['version', 'sequence', 'state', 'checksum']) ||
375
+ envelope.version !== 1 ||
376
+ !Number.isSafeInteger(envelope.sequence) ||
377
+ envelope.sequence < 1 ||
378
+ name !== `state-${envelope.sequence}.json` ||
379
+ envelope.checksum !== hashBytes(Buffer.from(JSON.stringify(envelope.state)))) throw recoveryError();
380
+ this.validateState(envelope.state);
381
+ records.push(record);
382
+ }
383
+ records.sort((a, b) => a.value.sequence - b.value.sequence);
384
+ if (records.length === 2 &&
385
+ records[1].value.sequence !== records[0].value.sequence + 1) throw recoveryError();
386
+ this.records = records;
387
+ this.record = records.at(-1)?.value.state ?? null;
388
+ }
389
+
390
+ validateState(state) {
391
+ if (!exactKeys(state, ['phase', 'base', 'draft', 'next', 'previous']) ||
392
+ !['idle', 'pending', 'updating', 'prepared', 'entered', 'failed'].includes(state.phase)) throw recoveryError();
393
+ for (const key of ['base', 'draft', 'next', 'previous']) {
394
+ if (state[key] !== null &&
395
+ !validDescriptor(state[key])) throw recoveryError();
396
+ }
397
+ if (state.phase === 'idle') {
398
+ if (state.base !== null ||
399
+ state.draft !== null ||
400
+ state.next !== null) throw recoveryError();
401
+ } else if (!state.base ||
402
+ !state.draft ||
403
+ (state.phase === 'updating' && !state.next) ||
404
+ (!['pending', 'updating'].includes(state.phase) && state.next)) throw recoveryError();
405
+ }
406
+
407
+ save(state) {
408
+ this.acquire();
409
+ this.validateState(state);
410
+ try {
411
+ // At most two bounded records exist, including the crash interval.
412
+ while (this.records.length > 1) this.removeMetadata(this.records.shift());
413
+ const sequence = (this.records[0]?.value.sequence ?? 0) + 1;
414
+ if (!Number.isSafeInteger(sequence)) throw recoveryError();
415
+ const record = this.writeMetadata(join(this.directory, `state-${sequence}.json`), {
416
+ version: 1, sequence, state,
417
+ checksum: hashBytes(Buffer.from(JSON.stringify(state))),
418
+ });
419
+ this.records.push(record);
420
+ this.record = state;
421
+ while (this.records.length > 1) this.removeMetadata(this.records.shift());
422
+ } catch (error) {
423
+ this.poisoned = true;
424
+ throw error;
425
+ }
426
+ }
427
+
428
+ idle(previous = this.record?.previous ?? null) {
429
+ this.save({ phase: 'idle', base: null, draft: null, next: null, previous });
430
+ }
431
+
432
+ topology(execution) {
433
+ const result = {};
434
+ for (const [key, path] of Object.entries({ active: this.active, ...this.paths })) {
435
+ result[key] = exists(path) ? this.inspect(path, execution, process.platform === 'win32' ? 1 : 2) : null;
436
+ }
437
+ return result;
438
+ }
439
+
440
+ discard(execution) {
441
+ this.acquire();
442
+ const state = this.record;
443
+ const topology = this.topology(execution);
444
+ const owned = [state?.draft, state?.next].filter(Boolean);
445
+ // Validate the entire set before deleting anything. A failed helper can
446
+ // leave an unknown candidate; its mere filename is never cleanup authority.
447
+ for (const key of ['pending', 'next', 'old']) {
448
+ if (topology[key] &&
449
+ !owned.some((descriptor) => same(descriptor, topology[key]))) throw recoveryError();
450
+ }
451
+ for (const key of ['pending', 'next', 'old']) {
452
+ if (topology[key]) this.remove(this.paths[key], topology[key], 2, execution);
453
+ }
454
+ this.idle(topology.previous ? state.previous : null);
455
+ }
456
+
457
+ recover(execution) {
458
+ execution?.check();
459
+ this.acquire();
460
+ if (!this.record) {
461
+ const topology = this.topology(execution);
462
+ if (topology.pending ||
463
+ topology.next ||
464
+ topology.old ||
465
+ topology.previous) throw recoveryError();
466
+ return { outcome: 'none' };
467
+ }
468
+ const state = this.record;
469
+ if (state.phase === 'failed') throw recoveryError();
470
+ const topology = this.topology(execution);
471
+ if (!same(topology.previous, state.previous) &&
472
+ (topology.previous || state.previous) &&
473
+ state.phase !== 'entered' &&
474
+ !(state.phase === 'prepared' && !topology.previous)) throw recoveryError();
475
+ if (state.phase === 'idle') {
476
+ if (topology.pending ||
477
+ topology.next ||
478
+ topology.old) throw recoveryError();
479
+ return { outcome: 'none' };
480
+ }
481
+ if (state.phase !== 'entered') {
482
+ this.discard(execution);
483
+ return { outcome: 'discarded', commitState: 'not-committed' };
484
+ }
485
+ if (topology.next ||
486
+ topology.old ||
487
+ (topology.previous && !same(topology.previous, state.base)) ||
488
+ (topology.pending && !same(topology.pending, state.draft))) throw recoveryError();
489
+ const candidatePath = topology.pending ? this.paths.pending : this.active;
490
+ const candidate = boundedRead(candidatePath, MAX_CONFIG_BYTES, 2);
491
+ new JsonSpans(candidate.bytes).validateComplete();
492
+ if (same(topology.active, state.base)) {
493
+ if (!topology.pending) throw recoveryError();
494
+ if (topology.previous) {
495
+ if (process.platform === 'win32') throw recoveryError();
496
+ this.remove(this.active, state.base, 2, execution);
497
+ } else {
498
+ this.move(this.active, this.paths.previous, state.base, execution);
499
+ }
500
+ } else if (topology.active &&
501
+ !same(topology.active, state.draft)) throw recoveryError();
502
+ if (!same(this.inspect(this.paths.previous, execution), state.base)) throw recoveryError();
503
+ if (!exists(this.active)) {
504
+ this.move(this.paths.pending, this.active, state.draft, execution);
505
+ } else {
506
+ this.verify(this.active, state.draft, execution, 2);
507
+ if (exists(this.paths.pending)) {
508
+ if (process.platform === 'win32') throw recoveryError();
509
+ this.remove(this.paths.pending, state.draft, 2, execution);
510
+ }
511
+ }
512
+ this.verify(this.active, state.draft, execution);
513
+ this.idle(state.base);
514
+ return { outcome: 'committed', commitState: 'committed', revision: state.draft.revision };
515
+ }
516
+
517
+ close() {
518
+ if (!this.owner) return;
519
+ this.removeMetadata(this.owner);
520
+ this.owner = null;
521
+ fs.rmdirSync(this.lock);
522
+ this.syncDirectory(this.directory);
523
+ }
524
+ }
525
+
526
+ export function hasPoolingTransaction(configPath) {
527
+ return exists(`${resolve(configPath)}.pooling-transaction`);
528
+ }
529
+
530
+ export function recoverPoolingConfig(configPath) {
531
+ const files = new PoolingFiles(configPath);
532
+ try {
533
+ return files.recover();
534
+ } catch (error) {
535
+ if (error instanceof PoolingConfigError &&
536
+ error.code === 'STORE_BUSY') throw error;
537
+ throw recoveryError();
538
+ } finally {
539
+ try { files.close(); } catch { throw recoveryError(); }
540
+ }
541
+ }