@memnexus-ai/mx-agent-cli 0.1.204 → 0.1.206
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/__tests__/claude-sync.test.js +42 -4
- package/dist/__tests__/claude-sync.test.js.map +1 -1
- package/dist/__tests__/config-generation-gate.test.d.ts +17 -4
- package/dist/__tests__/config-generation-gate.test.d.ts.map +1 -1
- package/dist/__tests__/config-generation-gate.test.js +112 -14
- package/dist/__tests__/config-generation-gate.test.js.map +1 -1
- package/dist/__tests__/generate-claude.test.js +112 -1
- package/dist/__tests__/generate-claude.test.js.map +1 -1
- package/dist/__tests__/start-sync-hardstop.test.d.ts +15 -0
- package/dist/__tests__/start-sync-hardstop.test.d.ts.map +1 -0
- package/dist/__tests__/start-sync-hardstop.test.js +121 -0
- package/dist/__tests__/start-sync-hardstop.test.js.map +1 -0
- package/dist/lib/claude.d.ts +0 -14
- package/dist/lib/claude.d.ts.map +1 -1
- package/dist/lib/claude.js +35 -0
- package/dist/lib/claude.js.map +1 -1
- package/package.json +1 -1
- package/scripts/generate-claude.mjs +205 -96
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
66
|
import {
|
|
67
|
-
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
|
|
68
|
-
rmSync, unlinkSync, writeFileSync,
|
|
67
|
+
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
|
|
68
|
+
renameSync, rmSync, unlinkSync, writeFileSync,
|
|
69
69
|
} from 'fs';
|
|
70
70
|
import { homedir, tmpdir } from 'os';
|
|
71
71
|
import { dirname, join, relative, resolve, sep } from 'path';
|
|
@@ -189,6 +189,88 @@ function pruneExtras(destDir, expected) {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
// ── GENERATE-TO-STAGING + ATOMIC SWAP (Phase 4 prereq; Red Team finding 1) ────
|
|
193
|
+
//
|
|
194
|
+
// Generation writes the WHOLE tree into a staging dir (a sibling of destRoot so
|
|
195
|
+
// the final rename stays on one filesystem), then atomically swaps it into place.
|
|
196
|
+
// A crash mid-generation can only ever leave a `.claude.staging-*` sibling behind
|
|
197
|
+
// — never a half-written `.claude/`. On ANY staging-phase failure the caller
|
|
198
|
+
// removes the staging dir and leaves the existing `.claude/` byte-untouched.
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Copy every existing-dest entry the current generation does NOT manage into the
|
|
202
|
+
* staging tree, so the swap loses nothing that lives under `.claude/` but is not
|
|
203
|
+
* generator-owned — chiefly `.claude/settings.local.json` (user-local, never
|
|
204
|
+
* generated), plus scheduled-task locks and, in the skipPermissionLayer degrade
|
|
205
|
+
* mode, the existing settings.json + hooks/ backstop (which staging does not
|
|
206
|
+
* contain in that mode). Anything the generator DID write into staging keeps its
|
|
207
|
+
* freshly generated version (managed wins) — which also preserves the historical
|
|
208
|
+
* prune behavior (a managed file removed upstream does not linger).
|
|
209
|
+
*
|
|
210
|
+
* FAILS CLOSED if the existing dest apex is a symlink, or if any entry nested in
|
|
211
|
+
* the existing dest is a symlink — carry-over copies plain files only, and the
|
|
212
|
+
* swap must never move a smuggled link into place. The real committed `.claude/`
|
|
213
|
+
* is symlink-free, so this is a no-op in the field.
|
|
214
|
+
*/
|
|
215
|
+
function carryOverUnmanaged(destRoot, staging) {
|
|
216
|
+
let st;
|
|
217
|
+
try { st = lstatSync(destRoot); } catch { return; } // no existing dest — nothing to carry
|
|
218
|
+
if (st.isSymbolicLink()) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`generate-claude: FAIL-CLOSED — existing ${destRoot} is a symlink; ` +
|
|
221
|
+
`refusing to carry over or swap through it.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
assertNoSymlinksInTree(destRoot, '.claude');
|
|
225
|
+
for (const ent of listDir(destRoot)) {
|
|
226
|
+
if (existsSync(join(staging, ent.name))) continue; // generator-managed — its version wins
|
|
227
|
+
cpSync(join(destRoot, ent.name), join(staging, ent.name), { recursive: true });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Atomically move `staging` into place at `destRoot`:
|
|
233
|
+
* 1. rename existing `destRoot` → `destRoot.prev-<ts>` (if it exists)
|
|
234
|
+
* 2. rename `staging` → `destRoot`
|
|
235
|
+
* 3. remove the prev dir on success
|
|
236
|
+
* If step 2 fails after step 1, restore the prev dir back to `destRoot`
|
|
237
|
+
* (best-effort, loud). The caller removes `staging` on any throw.
|
|
238
|
+
*/
|
|
239
|
+
function swapStagingIntoPlace(destRoot, staging) {
|
|
240
|
+
let destExists = true;
|
|
241
|
+
try { lstatSync(destRoot); } catch { destExists = false; }
|
|
242
|
+
const prev = `${destRoot}.prev-${Date.now()}-${process.pid}`;
|
|
243
|
+
if (destExists) renameSync(destRoot, prev);
|
|
244
|
+
try {
|
|
245
|
+
// Test-only crash injection (Change 3): simulate a failure AFTER the existing
|
|
246
|
+
// dest was moved to prev but BEFORE staging is swapped in, to prove the
|
|
247
|
+
// prev→destRoot restore path leaves the existing `.claude/` byte-identical
|
|
248
|
+
// with no staging/prev debris. Never set in production.
|
|
249
|
+
if (process.env.MX_GENCLAUDE_TEST_FAIL_MID_SWAP === '1') {
|
|
250
|
+
throw new Error('generate-claude: injected mid-swap failure (MX_GENCLAUDE_TEST_FAIL_MID_SWAP)');
|
|
251
|
+
}
|
|
252
|
+
renameSync(staging, destRoot);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (destExists) {
|
|
255
|
+
try {
|
|
256
|
+
renameSync(prev, destRoot);
|
|
257
|
+
process.stderr.write(
|
|
258
|
+
`generate-claude: WARN — staging swap failed; restored the previous ` +
|
|
259
|
+
`.claude/ from ${prev}. No changes were applied.\n`,
|
|
260
|
+
);
|
|
261
|
+
} catch (restoreErr) {
|
|
262
|
+
const d = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`generate-claude: CRITICAL — swap failed AND could not restore the previous ` +
|
|
265
|
+
`.claude/ from ${prev} (${d}). Recover manually: mv ${prev} ${destRoot}\n`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
if (destExists) rmSync(prev, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
192
274
|
// ── last-known-good permission-layer cache (Red Team CRITICAL B) ─────────────
|
|
193
275
|
//
|
|
194
276
|
// The boot degrade path must NOT trust the working tree. If the origin fetch of
|
|
@@ -398,109 +480,136 @@ export function generateClaudeConfig(opts) {
|
|
|
398
480
|
};
|
|
399
481
|
scanSources();
|
|
400
482
|
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
// abort FAIL-CLOSED instead of silently skipping the mutated entry.
|
|
411
|
-
scanSources();
|
|
483
|
+
// Build the FULL tree into a STAGING dir that is a sibling of destRoot (so the
|
|
484
|
+
// final rename stays on one filesystem), then atomically swap it into place. A
|
|
485
|
+
// crash mid-generation can only ever leave a `.claude.staging-*` sibling behind,
|
|
486
|
+
// never a half-written `.claude/`. All the fail-closed guards below still apply,
|
|
487
|
+
// now against staging (fresh mkdtemp — so a smuggled dest-side symlink cannot
|
|
488
|
+
// reach the write phase); the existing dest is guarded separately at swap time.
|
|
489
|
+
const parent = dirname(destRoot);
|
|
490
|
+
mkdirSync(parent, { recursive: true });
|
|
491
|
+
const staging = mkdtempSync(join(parent, '.claude.staging-'));
|
|
412
492
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
493
|
+
try {
|
|
494
|
+
// MINOR (scan-to-copy race hardening): re-scan the source immediately before
|
|
495
|
+
// the write phase. If a link was planted between the pre-scan and now (TOCTOU),
|
|
496
|
+
// abort FAIL-CLOSED instead of silently skipping the mutated entry.
|
|
497
|
+
scanSources();
|
|
498
|
+
|
|
499
|
+
// 1. settings.json — verbatim, 0644. From the permission source (origin/<base>
|
|
500
|
+
// at boot; baseDir otherwise). Skipped in degraded fallback.
|
|
501
|
+
if (!skipPermissionLayer) {
|
|
502
|
+
writeManagedFile(join(staging, 'settings.json'), readFileSync(join(permissionSrcDir, 'settings.json')), 0o644);
|
|
503
|
+
}
|
|
418
504
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
505
|
+
// 2. hooks/ — every file verbatim; *.sh → 0755 (incl. *.test.sh), else 0644.
|
|
506
|
+
// Permission layer: from permissionSrcDir. Skipped in degraded fallback.
|
|
507
|
+
let hookCount = 0;
|
|
508
|
+
if (!skipPermissionLayer) {
|
|
509
|
+
const hooksDest = guardManagedDir(join(staging, 'hooks'));
|
|
510
|
+
mkdirSync(hooksDest, { recursive: true });
|
|
511
|
+
const hookNames = new Set();
|
|
512
|
+
for (const ent of listDir(join(permissionSrcDir, 'hooks'))) {
|
|
513
|
+
if (!ent.isFile()) continue;
|
|
514
|
+
const mode = ent.name.endsWith('.sh') ? 0o755 : 0o644;
|
|
515
|
+
writeManagedFile(join(hooksDest, ent.name), readFileSync(join(permissionSrcDir, 'hooks', ent.name)), mode);
|
|
516
|
+
hookNames.add(ent.name);
|
|
517
|
+
}
|
|
518
|
+
pruneExtras(hooksDest, hookNames);
|
|
519
|
+
hookCount = hookNames.size;
|
|
431
520
|
}
|
|
432
|
-
pruneExtras(hooksDest, hookNames);
|
|
433
|
-
hookCount = hookNames.size;
|
|
434
|
-
}
|
|
435
521
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (!ent.isFile()) continue;
|
|
442
|
-
writeManagedFile(join(rulesDest, ent.name), readFileSync(join(baseDir, 'rules', ent.name)), 0o644);
|
|
443
|
-
ruleNames.add(ent.name);
|
|
444
|
-
}
|
|
445
|
-
pruneExtras(rulesDest, ruleNames);
|
|
446
|
-
|
|
447
|
-
// 4. agents/ — union of base + overlay, 0644. Overlay overlays base on a
|
|
448
|
-
// name collision (last write wins) — by design disjoint today.
|
|
449
|
-
const agentsDest = guardManagedDir(join(destRoot, 'agents'));
|
|
450
|
-
mkdirSync(agentsDest, { recursive: true });
|
|
451
|
-
const agentNames = new Set();
|
|
452
|
-
for (const src of [join(baseDir, 'agents'), join(overlayDir, 'agents')]) {
|
|
453
|
-
for (const ent of listDir(src)) {
|
|
522
|
+
// 3. rules/ — verbatim, 0644.
|
|
523
|
+
const rulesDest = guardManagedDir(join(staging, 'rules'));
|
|
524
|
+
mkdirSync(rulesDest, { recursive: true });
|
|
525
|
+
const ruleNames = new Set();
|
|
526
|
+
for (const ent of listDir(join(baseDir, 'rules'))) {
|
|
454
527
|
if (!ent.isFile()) continue;
|
|
455
|
-
writeManagedFile(join(
|
|
456
|
-
|
|
528
|
+
writeManagedFile(join(rulesDest, ent.name), readFileSync(join(baseDir, 'rules', ent.name)), 0o644);
|
|
529
|
+
ruleNames.add(ent.name);
|
|
457
530
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
copyFileTree(join(src, ent.name), d);
|
|
472
|
-
skillNames.add(ent.name);
|
|
531
|
+
pruneExtras(rulesDest, ruleNames);
|
|
532
|
+
|
|
533
|
+
// 4. agents/ — union of base + overlay, 0644. Overlay overlays base on a
|
|
534
|
+
// name collision (last write wins) — by design disjoint today.
|
|
535
|
+
const agentsDest = guardManagedDir(join(staging, 'agents'));
|
|
536
|
+
mkdirSync(agentsDest, { recursive: true });
|
|
537
|
+
const agentNames = new Set();
|
|
538
|
+
for (const src of [join(baseDir, 'agents'), join(overlayDir, 'agents')]) {
|
|
539
|
+
for (const ent of listDir(src)) {
|
|
540
|
+
if (!ent.isFile()) continue;
|
|
541
|
+
writeManagedFile(join(agentsDest, ent.name), readFileSync(join(src, ent.name)), 0o644);
|
|
542
|
+
agentNames.add(ent.name);
|
|
543
|
+
}
|
|
473
544
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
} else if (ent.isFile()) {
|
|
490
|
-
writeManagedFile(d, readFileSync(s), 0o644);
|
|
491
|
-
} else if (ent.isDirectory()) {
|
|
492
|
-
guardManagedDir(d); // CRITICAL A: guard per-command dir before mkdir
|
|
493
|
-
mkdirSync(d, { recursive: true });
|
|
494
|
-
copyFileTree(s, d);
|
|
545
|
+
pruneExtras(agentsDest, agentNames);
|
|
546
|
+
|
|
547
|
+
// 5. skills/ — union of base + overlay skill DIRECTORIES, recursive, 0644.
|
|
548
|
+
const skillsDest = guardManagedDir(join(staging, 'skills'));
|
|
549
|
+
mkdirSync(skillsDest, { recursive: true });
|
|
550
|
+
const skillNames = new Set();
|
|
551
|
+
for (const src of [join(baseDir, 'skills'), join(overlayDir, 'skills')]) {
|
|
552
|
+
for (const ent of listDir(src)) {
|
|
553
|
+
if (!ent.isDirectory()) continue;
|
|
554
|
+
const d = join(skillsDest, ent.name);
|
|
555
|
+
guardManagedDir(d); // CRITICAL A: guard per-skill dir before mkdir
|
|
556
|
+
mkdirSync(d, { recursive: true });
|
|
557
|
+
copyFileTree(join(src, ent.name), d);
|
|
558
|
+
skillNames.add(ent.name);
|
|
559
|
+
}
|
|
495
560
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
561
|
+
pruneExtras(skillsDest, skillNames);
|
|
562
|
+
|
|
563
|
+
// 6. commands/ — from overlay/commands only. All regular files now (the former
|
|
564
|
+
// mx-find/mx-rules/mx-save SYMLINKS were replaced by plain files), 0644.
|
|
565
|
+
const commandsSrc = join(overlayDir, 'commands');
|
|
566
|
+
const commandsDest = guardManagedDir(join(staging, 'commands'));
|
|
567
|
+
mkdirSync(commandsDest, { recursive: true });
|
|
568
|
+
const commandNames = new Set();
|
|
569
|
+
for (const ent of listDir(commandsSrc)) {
|
|
570
|
+
const s = join(commandsSrc, ent.name);
|
|
571
|
+
const d = join(commandsDest, ent.name);
|
|
572
|
+
if (ent.isSymbolicLink()) {
|
|
573
|
+
// Should be unreachable after the pre-scan; kept as defense-in-depth.
|
|
574
|
+
throw new Error(`generate-claude: FAIL-CLOSED — unexpected symlink in ${commandsSrc}: ${ent.name}`);
|
|
575
|
+
} else if (ent.isFile()) {
|
|
576
|
+
writeManagedFile(d, readFileSync(s), 0o644);
|
|
577
|
+
} else if (ent.isDirectory()) {
|
|
578
|
+
guardManagedDir(d); // CRITICAL A: guard per-command dir before mkdir
|
|
579
|
+
mkdirSync(d, { recursive: true });
|
|
580
|
+
copyFileTree(s, d);
|
|
581
|
+
}
|
|
582
|
+
commandNames.add(ent.name);
|
|
583
|
+
}
|
|
584
|
+
pruneExtras(commandsDest, commandNames);
|
|
499
585
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
586
|
+
// Defense-in-depth: the generated staging tree must contain zero symlinks
|
|
587
|
+
// (generation only emits plain files). Fail closed BEFORE the swap if not.
|
|
588
|
+
assertNoSymlinksInTree(staging, '.claude.staging');
|
|
589
|
+
|
|
590
|
+
// Test-only crash injection (Change 3): simulate a failure between a fully
|
|
591
|
+
// generated staging tree and the swap, to prove the existing `.claude/` is
|
|
592
|
+
// left byte-untouched and no staging debris survives. Never set in production.
|
|
593
|
+
if (process.env.MX_GENCLAUDE_TEST_FAIL_BEFORE_SWAP === '1') {
|
|
594
|
+
throw new Error('generate-claude: injected pre-swap failure (MX_GENCLAUDE_TEST_FAIL_BEFORE_SWAP)');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Carry over settings.local.json + every other unmanaged existing entry
|
|
598
|
+
// (guards the existing dest against symlinks — FAILS CLOSED on a pre-planted
|
|
599
|
+
// apex/subdir symlink), then atomically swap staging into place.
|
|
600
|
+
carryOverUnmanaged(destRoot, staging);
|
|
601
|
+
swapStagingIntoPlace(destRoot, staging);
|
|
602
|
+
|
|
603
|
+
return { destRoot, counts: {
|
|
604
|
+
hooks: hookCount, rules: ruleNames.size, agents: agentNames.size,
|
|
605
|
+
skills: skillNames.size, commands: commandNames.size,
|
|
606
|
+
} };
|
|
607
|
+
} catch (err) {
|
|
608
|
+
// Staging-phase failure: remove the staging dir and leave the existing
|
|
609
|
+
// `.claude/` completely untouched.
|
|
610
|
+
rmSync(staging, { recursive: true, force: true });
|
|
611
|
+
throw err;
|
|
612
|
+
}
|
|
504
613
|
}
|
|
505
614
|
|
|
506
615
|
// ── --from-git: source agent-config from a git ref (tamper-resistant) ────────
|