@claude-flow/cli 3.71.4 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/helpers/helpers.manifest.json +4 -4
  2. package/.claude/helpers/hook-handler.cjs +23 -10
  3. package/.claude/helpers/statusline.cjs +29 -14
  4. package/README.md +18 -3
  5. package/bin/cli.js +13 -18
  6. package/bin/mcp-server.js +13 -18
  7. package/catalog-manifest.json +2 -2
  8. package/dist/src/appliance/rvfa-distribution.js +2 -2
  9. package/dist/src/benchmarks/gaia-critic.d.ts +1 -7
  10. package/dist/src/benchmarks/gaia-critic.js +1 -7
  11. package/dist/src/benchmarks/gaia-decomposer.d.ts +1 -8
  12. package/dist/src/benchmarks/gaia-decomposer.js +1 -8
  13. package/dist/src/commands/agent-wasm.js +2 -2
  14. package/dist/src/commands/analyze.js +1 -1
  15. package/dist/src/commands/daemon.js +1 -1
  16. package/dist/src/commands/doctor.js +3 -2
  17. package/dist/src/commands/guidance.js +9 -9
  18. package/dist/src/commands/hooks.js +13 -8
  19. package/dist/src/commands/init.js +14 -16
  20. package/dist/src/commands/neural.js +8 -6
  21. package/dist/src/commands/route.js +1 -1
  22. package/dist/src/commands/ruvector/backup.js +7 -5
  23. package/dist/src/commands/ruvector/benchmark.js +6 -1
  24. package/dist/src/commands/ruvector/import.js +19 -5
  25. package/dist/src/commands/ruvector/init.js +1 -1
  26. package/dist/src/commands/start.js +1 -1
  27. package/dist/src/commands/status.js +1 -1
  28. package/dist/src/commands/task.js +18 -1
  29. package/dist/src/commands/update.js +1 -1
  30. package/dist/src/funnel/local-signals.js +7 -2
  31. package/dist/src/mcp-protocol.d.ts +90 -0
  32. package/dist/src/mcp-protocol.js +179 -0
  33. package/dist/src/mcp-server.d.ts +1 -0
  34. package/dist/src/mcp-server.js +20 -22
  35. package/dist/src/mcp-tools/agent-execute-core.js +9 -1
  36. package/dist/src/mcp-tools/agent-tools.d.ts +13 -0
  37. package/dist/src/mcp-tools/agent-tools.js +59 -8
  38. package/dist/src/mcp-tools/browser-argv.d.ts +9 -0
  39. package/dist/src/mcp-tools/browser-argv.js +33 -0
  40. package/dist/src/mcp-tools/browser-session-tools.js +4 -0
  41. package/dist/src/mcp-tools/browser-tools.js +12 -2
  42. package/dist/src/mcp-tools/coordination-tools.js +5 -4
  43. package/dist/src/mcp-tools/embeddings-tools.js +12 -4
  44. package/dist/src/mcp-tools/glob-exclusions.d.ts +6 -0
  45. package/dist/src/mcp-tools/glob-exclusions.js +81 -0
  46. package/dist/src/mcp-tools/hive-mind-tools.js +104 -35
  47. package/dist/src/mcp-tools/hooks-tools.js +12 -5
  48. package/dist/src/mcp-tools/import-limits.d.ts +4 -0
  49. package/dist/src/mcp-tools/import-limits.js +28 -0
  50. package/dist/src/mcp-tools/memory-tools.js +21 -42
  51. package/dist/src/mcp-tools/performance-tools.js +7 -2
  52. package/dist/src/mcp-tools/record-store.d.ts +8 -0
  53. package/dist/src/mcp-tools/record-store.js +98 -1
  54. package/dist/src/mcp-tools/security-tools.d.ts +9 -0
  55. package/dist/src/mcp-tools/security-tools.js +39 -3
  56. package/dist/src/mcp-tools/session-tools.js +5 -3
  57. package/dist/src/mcp-tools/swarm-tools.js +35 -17
  58. package/dist/src/mcp-tools/task-tools.js +68 -5
  59. package/dist/src/mcp-tools/terminal-tools.js +9 -0
  60. package/dist/src/mcp-tools/workflow-tools.js +13 -12
  61. package/dist/src/mcp-tools/x-federation-join.js +55 -14
  62. package/dist/src/memory/intelligence.d.ts +11 -0
  63. package/dist/src/memory/intelligence.js +84 -6
  64. package/dist/src/memory/memory-initializer.d.ts +6 -2
  65. package/dist/src/memory/memory-initializer.js +15 -6
  66. package/dist/src/ruvector/model-router.d.ts +6 -1
  67. package/dist/src/ruvector/model-router.js +16 -4
  68. package/dist/src/services/worker-daemon.js +30 -12
  69. package/dist/src/transfer/storage/gcs.js +3 -2
  70. package/package.json +1 -1
  71. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +1 -0
@@ -10,6 +10,15 @@
10
10
  * Created with ❤️ by ruv.io
11
11
  */
12
12
  import type { MCPTool } from './types.js';
13
+ /**
14
+ * Pattern-pack selection for the shared defender (#4785, ADR-517), read once
15
+ * per process from RUFLO_AIDEFENCE_PACKS:
16
+ * unset/empty current default (option omitted)
17
+ * true|false all enabled packs | built-in patterns only
18
+ * a,b,c exactly the named packs
19
+ * Anything else is refused rather than silently falling back.
20
+ */
21
+ export declare function aidefencePackSelection(raw?: string | undefined): boolean | string[] | undefined;
13
22
  /**
14
23
  * Export all security tools
15
24
  */
@@ -33,6 +33,42 @@ const wrapperStats = {
33
33
  quickScanCalls: 0,
34
34
  quickScanThreats: 0,
35
35
  };
36
+ const KNOWN_PACKS = new Set([
37
+ 'core',
38
+ 'tool_invocation',
39
+ 'exfil_url',
40
+ 'encoded_instruction',
41
+ 'slack_markup_forgery',
42
+ 'instruction_override_i18n',
43
+ ]);
44
+ /**
45
+ * Pattern-pack selection for the shared defender (#4785, ADR-517), read once
46
+ * per process from RUFLO_AIDEFENCE_PACKS:
47
+ * unset/empty current default (option omitted)
48
+ * true|false all enabled packs | built-in patterns only
49
+ * a,b,c exactly the named packs
50
+ * Anything else is refused rather than silently falling back.
51
+ */
52
+ export function aidefencePackSelection(raw = process.env.RUFLO_AIDEFENCE_PACKS) {
53
+ const value = raw?.trim();
54
+ if (!value)
55
+ return undefined;
56
+ const lower = value.toLowerCase();
57
+ if (lower === 'true')
58
+ return true;
59
+ if (lower === 'false')
60
+ return false;
61
+ const names = value.split(',').map(name => name.trim()).filter(Boolean);
62
+ const unknown = names.filter(name => !KNOWN_PACKS.has(name));
63
+ if (names.length === 0 || unknown.length > 0) {
64
+ throw new Error(`Invalid RUFLO_AIDEFENCE_PACKS=${JSON.stringify(value)}: use true, false, or a comma list of ${[...KNOWN_PACKS].join(', ')}`);
65
+ }
66
+ return [...new Set(names)];
67
+ }
68
+ function aidefenceOptions() {
69
+ const packs = aidefencePackSelection();
70
+ return packs === undefined ? { enableLearning: true } : { enableLearning: true, packs };
71
+ }
36
72
  /**
37
73
  * Get or create AIDefence instance (throws if unavailable)
38
74
  */
@@ -44,7 +80,7 @@ async function getAIDefence() {
44
80
  // First attempt - try to load via dynamic import (ESM)
45
81
  try {
46
82
  const aidefence = await import(packageName);
47
- const instance = aidefence.createAIDefence({ enableLearning: true });
83
+ const instance = aidefence.createAIDefence(aidefenceOptions());
48
84
  if (!instance) {
49
85
  throw new Error('createAIDefence returned null');
50
86
  }
@@ -81,7 +117,7 @@ async function getAIDefence() {
81
117
  // the module cache is stable across the await boundary.
82
118
  try {
83
119
  const aidefence = await import(packageName);
84
- const instance = aidefence.createAIDefence({ enableLearning: true });
120
+ const instance = aidefence.createAIDefence(aidefenceOptions());
85
121
  if (instance) {
86
122
  aidefenceInstance = instance;
87
123
  console.error(`[claude-flow] ${packageName} loaded after install (resolver path)`);
@@ -95,7 +131,7 @@ async function getAIDefence() {
95
131
  const modulePath = require.resolve(packageName);
96
132
  const cacheBust = `?t=${Date.now()}`;
97
133
  const aidefence = await import(`file://${modulePath}${cacheBust}`);
98
- const instance = aidefence.createAIDefence({ enableLearning: true });
134
+ const instance = aidefence.createAIDefence(aidefenceOptions());
99
135
  if (!instance) {
100
136
  throw new Error('createAIDefence returned null after install');
101
137
  }
@@ -9,6 +9,7 @@ import { gunzipSync } from 'node:zlib';
9
9
  import { join } from 'node:path';
10
10
  import { getProjectCwd } from './types.js';
11
11
  import { containedWritePath } from './path-containment.js';
12
+ import { MAX_IMPORT_BYTES, readImportFile } from './import-limits.js';
12
13
  import { mkdirRestricted, readFileMaybeEncrypted, writeFileRestricted, } from '../fs-secure.js';
13
14
  import { validateIdentifier, validateText } from './validate-input.js';
14
15
  function isRecord(value) {
@@ -729,7 +730,7 @@ export const sessionTools = [
729
730
  inputSchema: {
730
731
  type: 'object',
731
732
  properties: {
732
- inputPath: { type: 'string', description: 'Path to the session JSON file to import (or pass data)' },
733
+ inputPath: { type: 'string', description: 'Path to the session JSON file to import (or pass data); file and gzip output are each limited to 16 MiB' },
733
734
  data: { type: 'object', description: 'The session record itself, as written by session_export (or pass inputPath)' },
734
735
  name: { type: 'string', description: 'Override the imported session name' },
735
736
  activate: { type: 'boolean', description: 'Restore the imported session into the active stores' },
@@ -747,8 +748,9 @@ export const sessionTools = [
747
748
  if (!existsSync(inputPath))
748
749
  return { error: `File not found: ${inputPath}` };
749
750
  try {
750
- const bytes = readFileSync(inputPath);
751
- const content = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes;
751
+ const bytes = readImportFile(inputPath);
752
+ const content = bytes[0] === 0x1f && bytes[1] === 0x8b
753
+ ? gunzipSync(bytes, { maxOutputLength: MAX_IMPORT_BYTES }) : bytes;
752
754
  parsed = JSON.parse(content.toString('utf-8'));
753
755
  }
754
756
  catch (e) {
@@ -4,9 +4,9 @@
4
4
  * Tool definitions for swarm coordination with file-based state persistence.
5
5
  * Replaces previous stub implementations with real state tracking.
6
6
  */
7
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
7
+ import { closeSync, existsSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { nullPrototypeRecords } from './record-store.js';
9
+ import { nullPrototypeRecords, readPreservedJsonStore } from './record-store.js';
10
10
  import { getProjectCwd } from './types.js';
11
11
  import { validateIdentifier } from './validate-input.js';
12
12
  import { createApscState, isApscAgentEligible, normalizeApscConfig, recordApscSignal, } from '../services/pheromone-adaptive.js';
@@ -85,16 +85,17 @@ function reconcileOrphanSwarms(store) {
85
85
  // #2085 — exported so `agent-tools.ts agent_spawn` can push into
86
86
  // `swarm.agents` (the field `swarm_status` reads).
87
87
  export function loadSwarmStore() {
88
- let store = { swarms: {}, version: '3.0.0' };
89
- try {
90
- const path = getSwarmStatePath();
91
- if (existsSync(path)) {
92
- store = JSON.parse(readFileSync(path, 'utf-8'));
93
- }
94
- }
95
- catch { /* fall through with default */ }
96
- if (!store || typeof store !== 'object' || Array.isArray(store))
97
- store = { swarms: {}, version: '3.0.0' };
88
+ // #4737: a parse error used to fall through to an empty store, and the next
89
+ // save (or orphan reconcile) overwrote swarm-state.json. Only a missing
90
+ // file is empty; corrupt bytes are copied aside and the error is reported.
91
+ const store = readPreservedJsonStore(getSwarmStatePath(), 'swarm', () => ({ swarms: {}, version: '3.0.0' }), (candidate) => {
92
+ const swarms = candidate.swarms;
93
+ return swarms === undefined || (!!swarms && typeof swarms === 'object' && !Array.isArray(swarms));
94
+ });
95
+ if (!store.swarms)
96
+ store.swarms = {};
97
+ // Record ids come from MCP input. A null prototype stops `__proto__` and
98
+ // `constructor` from resolving onto Object.prototype.
98
99
  store.swarms = nullPrototypeRecords(store.swarms);
99
100
  // #1799 — reconcile orphans on every load and persist if anything changed.
100
101
  // Cheap (process.kill(pid, 0) is sub-millisecond) and means
@@ -369,14 +370,18 @@ export const swarmTools = [
369
370
  }
370
371
  const store = loadSwarmStore();
371
372
  const swarmId = input.swarmId;
372
- if (swarmId && store.swarms[swarmId]) {
373
+ if (swarmId && !store.swarms[swarmId]) {
374
+ return { success: false, error: `Swarm ${swarmId} not found` };
375
+ }
376
+ const { getLiveAgentIds } = await import('./agent-tools.js');
377
+ if (swarmId) {
373
378
  const swarm = store.swarms[swarmId];
374
379
  return {
375
380
  swarmId: swarm.swarmId,
376
381
  status: swarm.status,
377
382
  topology: swarm.topology,
378
383
  maxAgents: swarm.maxAgents,
379
- agentCount: swarm.agents.length,
384
+ agentCount: getLiveAgentIds(swarm.agents).length,
380
385
  taskCount: swarm.tasks.length,
381
386
  config: swarm.config,
382
387
  apsc: apscStateOf(swarm),
@@ -401,7 +406,7 @@ export const swarmTools = [
401
406
  status: latest.status,
402
407
  topology: latest.topology,
403
408
  maxAgents: latest.maxAgents,
404
- agentCount: latest.agents.length,
409
+ agentCount: getLiveAgentIds(latest.agents).length,
405
410
  taskCount: latest.tasks.length,
406
411
  config: latest.config,
407
412
  apsc: apscStateOf(latest),
@@ -433,7 +438,7 @@ export const swarmTools = [
433
438
  const swarmId = input.swarmId;
434
439
  // Find the swarm
435
440
  let target;
436
- if (swarmId && store.swarms[swarmId]) {
441
+ if (swarmId) {
437
442
  target = store.swarms[swarmId];
438
443
  }
439
444
  else {
@@ -456,6 +461,19 @@ export const swarmTools = [
456
461
  error: 'Swarm already terminated',
457
462
  };
458
463
  }
464
+ const { agentTools, getLiveAgentIds } = await import('./agent-tools.js');
465
+ const terminateAgent = agentTools.find(tool => tool.name === 'agent_terminate');
466
+ let agentsTerminated = 0;
467
+ for (const agentId of getLiveAgentIds(target.agents)) {
468
+ const result = await terminateAgent.handler({
469
+ agentId,
470
+ force: input.graceful === false,
471
+ });
472
+ if (!result.success) {
473
+ return { success: false, swarmId: target.swarmId, agentsTerminated, error: result.error };
474
+ }
475
+ agentsTerminated++;
476
+ }
459
477
  target.status = 'terminated';
460
478
  target.updatedAt = new Date().toISOString();
461
479
  saveSwarmStore(store);
@@ -464,7 +482,7 @@ export const swarmTools = [
464
482
  swarmId: target.swarmId,
465
483
  terminated: true,
466
484
  graceful: input.graceful ?? true,
467
- agentsTerminated: target.agents.length,
485
+ agentsTerminated,
468
486
  terminatedAt: target.updatedAt,
469
487
  };
470
488
  },
@@ -93,6 +93,37 @@ function releaseAgents(agentIds, taskId) {
93
93
  }
94
94
  });
95
95
  }
96
+ /**
97
+ * Stores `task_assign` may bind an agent from.
98
+ *
99
+ * The canonical file is `.claude-flow/agents/store.json`. `hive-mind_spawn`
100
+ * writes its workers to `.claude-flow/agents.json` instead (#1916), so an id
101
+ * that exists in either store is a real agent. A corrupt canonical store
102
+ * throws, matching the read this handler already performed, so a bad file is
103
+ * never replaced. The hive file stays best-effort: a corrupt copy must not
104
+ * hide agents that are present in the canonical store.
105
+ */
106
+ function assignableAgentStores() {
107
+ const canonical = readRecordStore(join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json'), 'agents', () => ({ agents: {} }));
108
+ const stores = [canonical.agents];
109
+ const hivePath = join(getProjectCwd(), STORAGE_DIR, 'agents.json');
110
+ try {
111
+ if (!existsSync(hivePath))
112
+ return stores;
113
+ const parsed = JSON.parse(readFileSync(hivePath, 'utf-8'));
114
+ const agents = parsed?.agents;
115
+ if (agents && typeof agents === 'object' && !Array.isArray(agents)) {
116
+ stores.push(agents);
117
+ }
118
+ }
119
+ catch {
120
+ // Best-effort, same as updateAgents.
121
+ }
122
+ return stores;
123
+ }
124
+ function agentIsAssignable(id, stores) {
125
+ return stores.some((agents) => Object.prototype.hasOwnProperty.call(agents, id) && Boolean(agents[id]));
126
+ }
96
127
  export const taskTools = [
97
128
  {
98
129
  name: 'task_create',
@@ -450,10 +481,20 @@ export const taskTools = [
450
481
  if (!task) {
451
482
  return { taskId, error: 'Task not found' };
452
483
  }
484
+ // Completed and cancelled tasks are terminal (#4725). Assigning a
485
+ // completed task marks the agent busy, and task_complete then returns
486
+ // without releasing it; task_cancel refuses a finished task too, so
487
+ // nothing ever sets that agent back to idle.
488
+ if (task.status === 'completed' || task.status === 'cancelled') {
489
+ return {
490
+ success: false,
491
+ taskId,
492
+ status: task.status,
493
+ error: `Cannot assign a ${task.status} task`,
494
+ };
495
+ }
453
496
  const previouslyAssigned = [...task.assignedTo];
454
- // Load agent store to sync worker state
455
- const agentStorePath = join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json');
456
- readRecordStore(agentStorePath, 'agents', () => ({ agents: {} }));
497
+ const agentStores = assignableAgentStores();
457
498
  if (input.unassign) {
458
499
  // Revert previously assigned agents to idle
459
500
  releaseAgents(previouslyAssigned, taskId);
@@ -461,6 +502,20 @@ export const taskTools = [
461
502
  }
462
503
  else {
463
504
  const agentIds = input.agentIds || [];
505
+ // An id in neither store is a ghost owner: the task would go
506
+ // in_progress with nobody to do the work (#4725).
507
+ if (Array.isArray(agentIds)) {
508
+ const missing = agentIds.filter((id) => typeof id === 'string' && !agentIsAssignable(id, agentStores));
509
+ if (missing.length > 0) {
510
+ const listed = missing.join(', ');
511
+ return {
512
+ success: false,
513
+ taskId,
514
+ status: task.status,
515
+ error: missing.length === 1 ? `Agent not found: ${listed}` : `Agents not found: ${listed}`,
516
+ };
517
+ }
518
+ }
464
519
  // Revert old agents to idle
465
520
  releaseAgents(previouslyAssigned.filter((id) => !agentIds.includes(id)), taskId);
466
521
  // Set new agents to active
@@ -569,21 +624,29 @@ export const taskTools = [
569
624
  const original = store.tasks[taskId];
570
625
  if (!original)
571
626
  return { success: false, taskId, error: 'Task not found' };
627
+ const retainState = input.resetState === false;
572
628
  const newTaskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
573
- store.tasks[newTaskId] = {
629
+ const retry = {
574
630
  taskId: newTaskId,
575
631
  type: original.type,
576
632
  description: original.description,
577
633
  priority: original.priority,
578
634
  status: 'pending',
579
- progress: 0,
635
+ progress: retainState ? original.progress ?? 0 : 0,
580
636
  assignedTo: [...original.assignedTo],
581
637
  tags: [...original.tags, 'retry-of:' + taskId],
582
638
  createdAt: new Date().toISOString(),
583
639
  startedAt: null,
584
640
  completedAt: null,
585
641
  };
642
+ if (retainState && original.result !== undefined)
643
+ retry.result = original.result;
644
+ store.tasks[newTaskId] = retry;
586
645
  saveTaskStore(store);
646
+ updateAgents(retry.assignedTo, (agent) => {
647
+ agent.status = 'busy';
648
+ agent.currentTask = newTaskId;
649
+ });
587
650
  return {
588
651
  taskId,
589
652
  newTaskId,
@@ -144,6 +144,15 @@ export const terminalTools = [
144
144
  }
145
145
  // Find or create default session
146
146
  let session = sessionId ? store.sessions[sessionId] : Object.values(store.sessions).find(s => s.status === 'active');
147
+ // #4730: terminal_close only flips status to "closed". Executing anyway
148
+ // ran the command and then wrote status back to "active", so a closed
149
+ // session kept accepting commands. Reject anything that is not active
150
+ // before the process is spawned and before that status write. When no
151
+ // sessionId was given, the lookup above already selected an active
152
+ // session (or we create one below).
153
+ if (session && session.status !== 'active') {
154
+ return { success: false, error: `Session is not active (${session.status})` };
155
+ }
147
156
  if (!session) {
148
157
  // Create default session
149
158
  const id = `term-${Date.now()}-${randomBytes(4).toString('hex')}`;
@@ -8,6 +8,7 @@ import { join } from 'node:path';
8
8
  import { getProjectCwd } from './types.js';
9
9
  import { validateIdentifier, validatePath, validateText } from './validate-input.js';
10
10
  import { executeAgentTask } from './agent-execute-core.js';
11
+ import { readPreservedJsonStore } from './record-store.js';
11
12
  // Storage paths
12
13
  const STORAGE_DIR = '.claude-flow';
13
14
  const WORKFLOW_DIR = 'workflows';
@@ -73,19 +74,19 @@ function withRecordMaps(store, keys) {
73
74
  return store;
74
75
  }
75
76
  const WORKFLOW_MAPS = ['workflows', 'templates'];
77
+ function isRecordMap(value) {
78
+ return !!value && typeof value === 'object' && !Array.isArray(value);
79
+ }
76
80
  function loadWorkflowStore() {
77
- try {
78
- const path = getWorkflowPath();
79
- if (existsSync(path)) {
80
- const data = JSON.parse(readFileSync(path, 'utf-8'));
81
- if (data && typeof data === 'object' && !Array.isArray(data))
82
- return withRecordMaps(data, WORKFLOW_MAPS);
83
- }
84
- }
85
- catch {
86
- // Return default store on error
87
- }
88
- return withRecordMaps({ workflows: {}, templates: {}, version: '3.0.0' }, WORKFLOW_MAPS);
81
+ // #4737: a parse error used to return an empty store, and the next save
82
+ // overwrote the file. Only a missing file is empty; corrupt bytes are
83
+ // copied aside and the error is reported.
84
+ const path = getWorkflowPath();
85
+ const data = readPreservedJsonStore(path, 'workflow', () => ({ workflows: {}, templates: {}, version: '3.0.0' }), (store) => WORKFLOW_MAPS.every((key) => {
86
+ const map = store[key];
87
+ return map === undefined || isRecordMap(map);
88
+ }));
89
+ return withRecordMaps(data, WORKFLOW_MAPS);
89
90
  }
90
91
  function saveWorkflowStore(store) {
91
92
  ensureWorkflowDir();
@@ -1,14 +1,53 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join, dirname } from 'node:path';
5
- // ADR-125 precedence: tool args (relayHttp / relayWs / keyFile) take precedence over the
6
- // RUFLO_X_RELAY_HTTP / RUFLO_X_RELAY_WS / RUFLO_NOSTR_KEY_FILE env vars, which precede defaults.
4
+ import { join, dirname, isAbsolute, relative, resolve } from 'node:path';
5
+ // Host configuration precedes defaults. Caller key overrides stay under ~/.ruflo;
6
+ // a caller relayWs must match the configured relay. relayHttp keeps ADR-125 precedence.
7
7
  const HTTP_BASE = (o) => (o || process.env.RUFLO_X_RELAY_HTTP || 'https://relay.ruv.io').replace(/\/$/, '');
8
8
  const RELAY_WS = (o) => o || process.env.RUFLO_X_RELAY_WS || 'wss://relay.ruv.io';
9
9
  const KEY_FILE = () => process.env.RUFLO_NOSTR_KEY_FILE || join(homedir(), '.ruflo', 'nostr.key');
10
10
  const hex = (b) => Buffer.from(b).toString('hex');
11
11
  const unhex = (h) => Uint8Array.from(Buffer.from(h, 'hex'));
12
+ function callerKeyFile(value) {
13
+ // Host configuration is trusted; an MCP argument cannot select a different host file.
14
+ if (value === undefined)
15
+ return KEY_FILE();
16
+ if (typeof value !== 'string' || !value)
17
+ throw new Error('keyFile must name a file under ~/.ruflo');
18
+ const root = resolve(homedir(), '.ruflo');
19
+ const target = resolve(value);
20
+ const rel = relative(root, target);
21
+ if (!rel || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\') || isAbsolute(rel)) {
22
+ throw new Error('keyFile must name a file under ~/.ruflo');
23
+ }
24
+ for (let current = target;; current = dirname(current)) {
25
+ try {
26
+ if (lstatSync(current).isSymbolicLink())
27
+ throw new Error('keyFile must not follow symlinks');
28
+ }
29
+ catch (error) {
30
+ if (error.code !== 'ENOENT')
31
+ throw error;
32
+ }
33
+ if (relative(root, current) === '')
34
+ break;
35
+ }
36
+ return target;
37
+ }
38
+ function configuredRelay(value) {
39
+ const configured = new URL(RELAY_WS());
40
+ if (value !== undefined && typeof value !== 'string')
41
+ throw new Error('relayWs must be the configured relay URL');
42
+ const requested = new URL(value === undefined ? configured.href : value);
43
+ const localTestRelay = Boolean(process.env.RUFLO_X_RELAY_WS)
44
+ && configured.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(configured.hostname);
45
+ if ((!localTestRelay && configured.protocol !== 'wss:') || requested.href !== configured.href
46
+ || requested.username || requested.password || requested.hash) {
47
+ throw new Error('relayWs must use wss and match the configured relay (explicit loopback ws configuration is allowed)');
48
+ }
49
+ return requested.href;
50
+ }
12
51
  async function loadNostrTools() {
13
52
  try {
14
53
  return (await import('nostr-tools/pure'));
@@ -24,7 +63,7 @@ export function loadOrCreateKey(nt, file = KEY_FILE()) {
24
63
  }
25
64
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
26
65
  const sk = nt.generateSecretKey();
27
- writeFileSync(file, hex(sk), { mode: 0o600 });
66
+ writeFileSync(file, hex(sk), { mode: 0o600, flag: 'wx' });
28
67
  return { sk, pubkey: nt.getPublicKey(sk), created: true };
29
68
  }
30
69
  export function nip98Header(nt, sk, url, method, body) {
@@ -80,24 +119,26 @@ export const xFederationJoinTools = [{
80
119
  code: { type: 'string', description: 'Optional private invite code (v2.…), for invite-only relays.' },
81
120
  gatewayUrl: { type: 'string', description: 'Public registration gateway origin; defaults to https://x.ruv.io.' },
82
121
  relayHttp: { type: 'string', description: 'Relay HTTPS base for the claim; takes precedence over RUFLO_X_RELAY_HTTP.' },
83
- relayWs: { type: 'string', description: 'Relay wss URL for NIP-42; takes precedence over RUFLO_X_RELAY_WS.' },
84
- keyFile: { type: 'string', description: 'Key file path; takes precedence over RUFLO_NOSTR_KEY_FILE (default ~/.ruflo/nostr.key).' }
122
+ relayWs: { type: 'string', description: 'Relay wss URL for NIP-42; must match RUFLO_X_RELAY_WS or the default relay.' },
123
+ keyFile: { type: 'string', description: 'Caller-selected file under ~/.ruflo (no symlinks). Omit to use host RUFLO_NOSTR_KEY_FILE or ~/.ruflo/nostr.key.' }
85
124
  }, required: [] },
86
125
  handler: async (input) => {
87
126
  const i = input;
88
- const nt = await loadNostrTools();
89
127
  // Validate input before the optional-dependency check so a bad code fails fast and identically
90
128
  // whether or not nostr-tools is present.
91
129
  if (i.code !== undefined && !/^v2\.[A-Za-z0-9._-]{8,}$/.test(i.code))
92
130
  throw new Error('invite code must look like v2.<token>');
131
+ const registrationBase = i.code ? undefined : secureBase(i.gatewayUrl || process.env.RUFLO_X_GATEWAY_URL || 'https://x.ruv.io');
132
+ const keyFile = callerKeyFile(i.keyFile);
133
+ const relayWs = configuredRelay(i.relayWs);
134
+ const nt = await loadNostrTools();
93
135
  if (!nt)
94
136
  return { degraded: true, reason: 'nostr-tools not installed', hint: 'npm i -g nostr-tools (secp256k1 signing is not in node:crypto)' };
95
- const registrationBase = i.code ? undefined : secureBase(i.gatewayUrl || process.env.RUFLO_X_GATEWAY_URL || 'https://x.ruv.io');
96
- const { sk, pubkey, created } = loadOrCreateKey(nt, i.keyFile);
137
+ const { sk, pubkey, created } = loadOrCreateKey(nt, keyFile);
97
138
  // Existing members need no new grant. In particular never downgrade/re-admit
98
139
  // their key just because they run join again.
99
140
  if (!i.code) {
100
- const existing = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
141
+ const existing = await verifyMembership(nt, sk, relayWs);
101
142
  if (existing.ok)
102
143
  return { ok: true, pubkey, keyCreated: created, membershipVerified: true, alreadyMember: true };
103
144
  }
@@ -108,7 +149,7 @@ export const xFederationJoinTools = [{
108
149
  r = await fetch(url, { method: 'POST', redirect: 'error', headers: { Authorization: nip98Header(nt, sk, url, 'POST', body), 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(20_000) });
109
150
  }
110
151
  catch {
111
- const check = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
152
+ const check = await verifyMembership(nt, sk, relayWs);
112
153
  if (check.ok)
113
154
  return { ok: true, pubkey, keyCreated: created, membershipVerified: true };
114
155
  throw new Error('Admission response unavailable and relay membership unverified. Keep your local key and retry; do not create another identity.');
@@ -117,12 +158,12 @@ export const xFederationJoinTools = [{
117
158
  if (!r.ok) {
118
159
  // A lost acknowledgment can follow a successful grant. Check with the
119
160
  // relay before declaring failure; never silently generate a second key.
120
- const check = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
161
+ const check = await verifyMembership(nt, sk, relayWs);
121
162
  if (check.ok)
122
163
  return { ok: true, pubkey, keyCreated: created, membershipVerified: true };
123
164
  throw new Error(`join rejected (${r.status}): ${claim.error ?? claim.message ?? 'unknown'}`);
124
165
  }
125
- const auth = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
166
+ const auth = await verifyMembership(nt, sk, relayWs);
126
167
  return { ok: auth.ok, pubkey, keyCreated: created, role: claim.role ?? 'member', membershipVerified: auth.ok, ...(auth.ok ? {} : { reason: auth.reason }),
127
168
  next: 'Publish kind-1 events tagged ["t","ruflo-swarm"] — or run `ruflo federation sync` to read the swarm.' };
128
169
  },
@@ -152,6 +152,12 @@ declare class LocalReasoningBank {
152
152
  private persistenceEnabled;
153
153
  private dirty;
154
154
  private saveTimeout;
155
+ /** IDs changed locally since the last successful flush. */
156
+ private dirtyIds;
157
+ /** IDs deleted locally; a disk copy must not be resurrected. */
158
+ private deletedIds;
159
+ /** A local clear() must remove disk entries too. */
160
+ private clearRequested;
155
161
  constructor(options: {
156
162
  maxSize: number;
157
163
  persistence?: boolean;
@@ -168,6 +174,11 @@ declare class LocalReasoningBank {
168
174
  private saveToDisk;
169
175
  /**
170
176
  * Immediately flush patterns to disk
177
+ *
178
+ * Re-reads the file under the cooperating-writer lock and merges only the
179
+ * patterns this process changed, so a long-lived daemon cannot erase patterns
180
+ * written by another hook/process (#4735). Deletions and clear() are carried
181
+ * as tombstones; the write itself is temp-file + rename.
171
182
  */
172
183
  flushToDisk(): void;
173
184
  /**