@ruvector/edge-net 0.5.1 → 0.5.3

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.
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Edge-Net Secure Plugin Loader
3
3
  *
4
+ * Cogito, Creo, Codex — Plugins extend, Core enforces.
5
+ *
4
6
  * Features:
5
7
  * - Ed25519 signature verification
6
8
  * - SHA-256 integrity checks
7
9
  * - Lazy loading with caching
8
10
  * - Capability-based sandboxing
11
+ * - Plugin Failure Isolation
12
+ * - Economic boundary enforcement
9
13
  * - Zero telemetry
10
14
  *
11
15
  * @module @ruvector/edge-net/plugins/loader
@@ -15,6 +19,274 @@ import { EventEmitter } from 'events';
15
19
  import { createHash, createVerify, generateKeyPairSync, sign, verify } from 'crypto';
16
20
  import { PLUGIN_CATALOG, PLUGIN_BUNDLES, Capability, PluginTier } from './plugin-manifest.js';
17
21
 
22
+ // ============================================
23
+ // PLUGIN FAILURE CONTRACT
24
+ // ============================================
25
+
26
+ /**
27
+ * PluginFailureContract - Defines what happens when plugins fail
28
+ *
29
+ * Contract guarantees:
30
+ * 1. Plugin failures NEVER crash core
31
+ * 2. Failed plugins enter quarantine
32
+ * 3. Core continues with degraded functionality
33
+ * 4. Failures are logged for diagnostics
34
+ */
35
+ export class PluginFailureContract extends EventEmitter {
36
+ constructor(options = {}) {
37
+ super();
38
+
39
+ this.config = {
40
+ // Retry policy
41
+ maxRetries: options.maxRetries ?? 3,
42
+ retryDelayMs: options.retryDelayMs ?? 1000,
43
+ retryBackoffMultiplier: options.retryBackoffMultiplier ?? 2,
44
+
45
+ // Quarantine policy
46
+ quarantineDurationMs: options.quarantineDurationMs ?? 5 * 60 * 1000, // 5 minutes
47
+ maxQuarantineCount: options.maxQuarantineCount ?? 3,
48
+
49
+ // Timeout policy
50
+ executionTimeoutMs: options.executionTimeoutMs ?? 5000,
51
+
52
+ // Memory management
53
+ maxFailureHistory: options.maxFailureHistory ?? 100, // Limit failure records per plugin
54
+ };
55
+
56
+ // Failure tracking
57
+ this.failures = new Map(); // pluginId -> FailureRecord[]
58
+ this.quarantine = new Map(); // pluginId -> QuarantineRecord
59
+ this.circuitBreakers = new Map(); // pluginId -> { open, openedAt, failures }
60
+ this._quarantineTimers = new Map(); // pluginId -> timerId (prevent timer stacking)
61
+ }
62
+
63
+ /**
64
+ * Record a plugin failure
65
+ */
66
+ recordFailure(pluginId, error, context = {}) {
67
+ if (!this.failures.has(pluginId)) {
68
+ this.failures.set(pluginId, []);
69
+ }
70
+
71
+ const record = {
72
+ error: error.message,
73
+ stack: error.stack,
74
+ context,
75
+ timestamp: Date.now(),
76
+ };
77
+
78
+ const failures = this.failures.get(pluginId);
79
+ failures.push(record);
80
+
81
+ // Prune old failures to prevent memory leak
82
+ if (failures.length > this.config.maxFailureHistory) {
83
+ failures.splice(0, failures.length - this.config.maxFailureHistory);
84
+ }
85
+
86
+ // Update circuit breaker
87
+ this._updateCircuitBreaker(pluginId);
88
+
89
+ this.emit('plugin:failure', { pluginId, ...record });
90
+
91
+ return record;
92
+ }
93
+
94
+ /**
95
+ * Update circuit breaker state
96
+ * @private
97
+ */
98
+ _updateCircuitBreaker(pluginId) {
99
+ if (!this.circuitBreakers.has(pluginId)) {
100
+ this.circuitBreakers.set(pluginId, { open: false, openedAt: null, failures: 0 });
101
+ }
102
+
103
+ const breaker = this.circuitBreakers.get(pluginId);
104
+ breaker.failures++;
105
+
106
+ if (breaker.failures >= this.config.maxRetries) {
107
+ breaker.open = true;
108
+ breaker.openedAt = Date.now();
109
+ this._quarantinePlugin(pluginId, 'circuit_breaker_tripped');
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Quarantine a failed plugin
115
+ * @private
116
+ */
117
+ _quarantinePlugin(pluginId, reason) {
118
+ const existingQuarantine = this.quarantine.get(pluginId);
119
+ const quarantineCount = existingQuarantine ? existingQuarantine.count + 1 : 1;
120
+
121
+ const record = {
122
+ pluginId,
123
+ reason,
124
+ count: quarantineCount,
125
+ startedAt: Date.now(),
126
+ expiresAt: Date.now() + this.config.quarantineDurationMs,
127
+ permanent: quarantineCount >= this.config.maxQuarantineCount,
128
+ };
129
+
130
+ this.quarantine.set(pluginId, record);
131
+
132
+ this.emit('plugin:quarantined', record);
133
+
134
+ // Clear existing timer to prevent stacking
135
+ const existingTimer = this._quarantineTimers.get(pluginId);
136
+ if (existingTimer) {
137
+ clearTimeout(existingTimer);
138
+ }
139
+
140
+ // Schedule unquarantine if not permanent
141
+ if (!record.permanent) {
142
+ const timerId = setTimeout(() => {
143
+ this._quarantineTimers.delete(pluginId);
144
+ this._tryUnquarantine(pluginId);
145
+ }, this.config.quarantineDurationMs);
146
+ this._quarantineTimers.set(pluginId, timerId);
147
+ }
148
+
149
+ return record;
150
+ }
151
+
152
+ /**
153
+ * Try to release plugin from quarantine
154
+ * @private
155
+ */
156
+ _tryUnquarantine(pluginId) {
157
+ const record = this.quarantine.get(pluginId);
158
+ if (!record || record.permanent) return;
159
+
160
+ if (Date.now() >= record.expiresAt) {
161
+ // Reset circuit breaker
162
+ const breaker = this.circuitBreakers.get(pluginId);
163
+ if (breaker) {
164
+ breaker.open = false;
165
+ breaker.failures = 0;
166
+ }
167
+
168
+ this.quarantine.delete(pluginId);
169
+ this.emit('plugin:unquarantined', { pluginId });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Check if plugin can execute
175
+ */
176
+ canExecute(pluginId) {
177
+ const quarantine = this.quarantine.get(pluginId);
178
+ if (quarantine) {
179
+ if (quarantine.permanent) {
180
+ return { allowed: false, reason: 'Permanently quarantined', permanent: true };
181
+ }
182
+ if (Date.now() < quarantine.expiresAt) {
183
+ const remainingMs = quarantine.expiresAt - Date.now();
184
+ return { allowed: false, reason: 'In quarantine', remainingMs };
185
+ }
186
+ }
187
+
188
+ const breaker = this.circuitBreakers.get(pluginId);
189
+ if (breaker?.open) {
190
+ return { allowed: false, reason: 'Circuit breaker open' };
191
+ }
192
+
193
+ return { allowed: true };
194
+ }
195
+
196
+ /**
197
+ * Execute with failure isolation
198
+ */
199
+ async executeIsolated(pluginId, fn, context = {}) {
200
+ const canExec = this.canExecute(pluginId);
201
+ if (!canExec.allowed) {
202
+ throw new Error(`Plugin ${pluginId} blocked: ${canExec.reason}`);
203
+ }
204
+
205
+ // Create timeout with proper cleanup
206
+ let timeoutId;
207
+ const timeoutPromise = new Promise((_, reject) => {
208
+ timeoutId = setTimeout(
209
+ () => reject(new Error('Execution timeout')),
210
+ this.config.executionTimeoutMs
211
+ );
212
+ });
213
+
214
+ try {
215
+ // Execute with timeout
216
+ const result = await Promise.race([fn(), timeoutPromise]);
217
+
218
+ // Success - reset failure count for this plugin
219
+ const breaker = this.circuitBreakers.get(pluginId);
220
+ if (breaker) {
221
+ breaker.failures = Math.max(0, breaker.failures - 1);
222
+ }
223
+
224
+ return result;
225
+ } catch (error) {
226
+ this.recordFailure(pluginId, error, context);
227
+ throw error;
228
+ } finally {
229
+ // Always clean up timeout to prevent memory leak
230
+ clearTimeout(timeoutId);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Get plugin health status
236
+ */
237
+ getHealth(pluginId) {
238
+ const failures = this.failures.get(pluginId) || [];
239
+ const quarantine = this.quarantine.get(pluginId);
240
+ const breaker = this.circuitBreakers.get(pluginId);
241
+
242
+ return {
243
+ pluginId,
244
+ healthy: !quarantine && !breaker?.open,
245
+ failureCount: failures.length,
246
+ recentFailures: failures.slice(-5),
247
+ quarantine: quarantine ? {
248
+ reason: quarantine.reason,
249
+ permanent: quarantine.permanent,
250
+ expiresAt: quarantine.expiresAt,
251
+ } : null,
252
+ circuitBreaker: breaker ? {
253
+ open: breaker.open,
254
+ failures: breaker.failures,
255
+ } : null,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Get overall health summary
261
+ */
262
+ getSummary() {
263
+ return {
264
+ totalPlugins: this.circuitBreakers.size,
265
+ quarantined: this.quarantine.size,
266
+ permanentlyQuarantined: Array.from(this.quarantine.values()).filter(q => q.permanent).length,
267
+ circuitBreakersOpen: Array.from(this.circuitBreakers.values()).filter(b => b.open).length,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Clean up all timers and resources
273
+ */
274
+ destroy() {
275
+ // Clear all quarantine timers
276
+ for (const timerId of this._quarantineTimers.values()) {
277
+ clearTimeout(timerId);
278
+ }
279
+ this._quarantineTimers.clear();
280
+
281
+ // Clear all tracking data
282
+ this.failures.clear();
283
+ this.quarantine.clear();
284
+ this.circuitBreakers.clear();
285
+
286
+ this.removeAllListeners();
287
+ }
288
+ }
289
+
18
290
  // ============================================
19
291
  // ED25519 SIGNATURE VERIFICATION
20
292
  // ============================================
@@ -143,6 +415,15 @@ export class PluginLoader extends EventEmitter {
143
415
  rateLimitEnabled: options.rateLimitEnabled ?? true,
144
416
  rateLimitRequests: options.rateLimitRequests ?? 100,
145
417
  rateLimitWindowMs: options.rateLimitWindowMs ?? 60000,
418
+
419
+ // Failure isolation
420
+ failureIsolation: options.failureIsolation ?? true,
421
+ maxRetries: options.maxRetries ?? 3,
422
+ quarantineDurationMs: options.quarantineDurationMs ?? 5 * 60 * 1000,
423
+ executionTimeoutMs: options.executionTimeoutMs ?? 5000,
424
+
425
+ // Economic boundary (CoreInvariants integration)
426
+ coreInvariants: options.coreInvariants ?? null,
146
427
  ...options,
147
428
  };
148
429
 
@@ -157,6 +438,21 @@ export class PluginLoader extends EventEmitter {
157
438
  windowMs: this.options.rateLimitWindowMs,
158
439
  });
159
440
 
441
+ // Failure isolation contract
442
+ this.failureContract = new PluginFailureContract({
443
+ maxRetries: this.options.maxRetries,
444
+ quarantineDurationMs: this.options.quarantineDurationMs,
445
+ executionTimeoutMs: this.options.executionTimeoutMs,
446
+ });
447
+
448
+ // Wire failure events
449
+ this.failureContract.on('plugin:failure', (data) => {
450
+ this.emit('plugin:failure', data);
451
+ });
452
+ this.failureContract.on('plugin:quarantined', (data) => {
453
+ this.emit('plugin:quarantined', data);
454
+ });
455
+
160
456
  // Stats
161
457
  this.stats = {
162
458
  loaded: 0,
@@ -164,9 +460,18 @@ export class PluginLoader extends EventEmitter {
164
460
  verified: 0,
165
461
  rejected: 0,
166
462
  rateLimited: 0,
463
+ quarantined: 0,
464
+ failures: 0,
167
465
  };
168
466
  }
169
467
 
468
+ /**
469
+ * Set CoreInvariants for economic boundary enforcement
470
+ */
471
+ setCoreInvariants(coreInvariants) {
472
+ this.options.coreInvariants = coreInvariants;
473
+ }
474
+
170
475
  /**
171
476
  * Get catalog of available plugins
172
477
  */
@@ -404,6 +709,11 @@ export class PluginLoader extends EventEmitter {
404
709
  '__dirname', '__filename', 'module', 'exports'
405
710
  ]);
406
711
 
712
+ // Get economic boundary from CoreInvariants if available
713
+ const economicView = this.options.coreInvariants
714
+ ? this.options.coreInvariants.getPluginEconomicView()
715
+ : this._createMockEconomicView();
716
+
407
717
  // Create isolated sandbox context
408
718
  const sandbox = {
409
719
  // Immutable capability set
@@ -421,6 +731,10 @@ export class PluginLoader extends EventEmitter {
421
731
  }
422
732
  },
423
733
 
734
+ // Economic boundary (READ-ONLY)
735
+ // Plugins can observe credits but NEVER modify
736
+ credits: economicView,
737
+
424
738
  // Resource limits
425
739
  limits: Object.freeze({
426
740
  maxMemoryMB: 128,
@@ -461,6 +775,72 @@ export class PluginLoader extends EventEmitter {
461
775
  return Object.freeze(sandbox);
462
776
  }
463
777
 
778
+ /**
779
+ * Create mock economic view when CoreInvariants not available
780
+ * @private
781
+ */
782
+ _createMockEconomicView() {
783
+ return Object.freeze({
784
+ getBalance: () => 0,
785
+ getTransactionHistory: () => [],
786
+ getSummary: () => Object.freeze({ balance: 0, transactions: 0 }),
787
+ on: () => {},
788
+ mint: () => { throw new Error('INVARIANT VIOLATION: Plugins cannot mint credits'); },
789
+ burn: () => { throw new Error('INVARIANT VIOLATION: Plugins cannot burn credits'); },
790
+ settle: () => { throw new Error('INVARIANT VIOLATION: Plugins cannot settle credits'); },
791
+ transfer: () => { throw new Error('INVARIANT VIOLATION: Plugins cannot transfer credits'); },
792
+ });
793
+ }
794
+
795
+ /**
796
+ * Execute plugin function with failure isolation
797
+ * Core NEVER crashes from plugin failures
798
+ */
799
+ async execute(pluginId, fnName, args = []) {
800
+ const plugin = this.loadedPlugins.get(pluginId);
801
+ if (!plugin) {
802
+ throw new Error(`Plugin not loaded: ${pluginId}`);
803
+ }
804
+
805
+ // Check if plugin is allowed to execute
806
+ const canExec = this.failureContract.canExecute(pluginId);
807
+ if (!canExec.allowed) {
808
+ this.stats.quarantined++;
809
+ throw new Error(`Plugin ${pluginId} blocked: ${canExec.reason}`);
810
+ }
811
+
812
+ // Execute with failure isolation
813
+ try {
814
+ const result = await this.failureContract.executeIsolated(
815
+ pluginId,
816
+ async () => {
817
+ const fn = plugin.api?.[fnName] || plugin[fnName];
818
+ if (typeof fn !== 'function') {
819
+ throw new Error(`Plugin ${pluginId} has no function: ${fnName}`);
820
+ }
821
+ return fn.apply(plugin, args);
822
+ },
823
+ { fnName, args }
824
+ );
825
+
826
+ return result;
827
+ } catch (error) {
828
+ this.stats.failures++;
829
+ // Re-throw but core doesn't crash
830
+ throw error;
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Get plugin health including failure status
836
+ */
837
+ getHealth(pluginId) {
838
+ if (!pluginId) {
839
+ return this.failureContract.getSummary();
840
+ }
841
+ return this.failureContract.getHealth(pluginId);
842
+ }
843
+
464
844
  /**
465
845
  * Create plugin API based on capabilities
466
846
  */
@@ -622,6 +1002,7 @@ export class PluginLoader extends EventEmitter {
622
1002
  ...this.stats,
623
1003
  catalogSize: Object.keys(PLUGIN_CATALOG).length,
624
1004
  bundleCount: Object.keys(PLUGIN_BUNDLES).length,
1005
+ health: this.failureContract.getSummary(),
625
1006
  };
626
1007
  }
627
1008
  }