@evomap/evolver 1.91.2 → 1.92.1
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/README.md +13 -7
- package/index.js +150 -71
- package/package.json +2 -1
- package/src/adapters/scripts/_runtimePaths.js +10 -1
- package/src/adapters/scripts/evolver-session-end.js +10 -1
- package/src/canonicalIdentityLock.js +455 -0
- package/src/evolve/guards.js +1 -1
- package/src/evolve/pipeline/collect.js +1 -1
- package/src/evolve/pipeline/dispatch.js +1 -1
- package/src/evolve/pipeline/enrich.js +1 -1
- package/src/evolve/pipeline/hub.js +1 -1
- package/src/evolve/pipeline/select.js +1 -1
- package/src/evolve/pipeline/signals.js +1 -1
- package/src/evolve/utils.js +1 -1
- package/src/evolve.js +1 -1
- package/src/gep/a2aProtocol.js +1 -5175
- package/src/gep/antiAbuseTelemetry.js +1 -233
- package/src/gep/assetStore.js +56 -12
- package/src/gep/autoDistillConv.js +1 -205
- package/src/gep/autoDistillLlm.js +1 -315
- package/src/gep/candidateEval.js +1 -92
- package/src/gep/candidates.js +1 -1
- package/src/gep/contentHash.js +1 -30
- package/src/gep/conversationDistiller.js +1 -270
- package/src/gep/conversationSniffer.js +1 -266
- package/src/gep/crypto.js +1 -93
- package/src/gep/curriculum.js +1 -1
- package/src/gep/deviceId.js +1 -218
- package/src/gep/envFingerprint.js +1 -118
- package/src/gep/epigenetics.js +1 -31
- package/src/gep/execBridge.js +1 -712
- package/src/gep/explore.js +1 -289
- package/src/gep/hash.js +1 -15
- package/src/gep/hubFetch.js +1 -672
- package/src/gep/hubReview.js +1 -212
- package/src/gep/hubSearch.js +1 -544
- package/src/gep/hubVerify.js +1 -306
- package/src/gep/learningSignals.js +1 -1
- package/src/gep/memoryGraph.js +1 -1449
- package/src/gep/memoryGraphAdapter.js +1 -203
- package/src/gep/mutation.js +1 -1
- package/src/gep/narrativeMemory.js +1 -1
- package/src/gep/openPRRegistry.js +1 -205
- package/src/gep/personality.js +1 -1
- package/src/gep/policyCheck.js +1 -599
- package/src/gep/prompt.js +1 -1
- package/src/gep/recallInject.js +1 -409
- package/src/gep/recallVerifier.js +1 -318
- package/src/gep/reflection.js +1 -1
- package/src/gep/savingsCore.js +1 -1
- package/src/gep/schemas/gene.js +2 -0
- package/src/gep/selector.js +1 -1
- package/src/gep/skillDistiller.js +1 -1294
- package/src/gep/solidify.js +1 -1
- package/src/gep/strategy.js +1 -136
- package/src/gep/syncAsset.js +1 -0
- package/src/gep/tokenSavings.js +1 -1
- package/src/gep/trajectoryExport.js +1 -3841
- package/src/gep/workspaceKeychain.js +1 -174
- package/src/proxy/extensions/traceControl.js +1 -123
- package/src/proxy/index.js +136 -8
- package/src/proxy/inject.js +1 -52
- package/src/proxy/lifecycle/manager.js +279 -18
- package/src/proxy/mailbox/state.js +60 -29
- package/src/proxy/trace/extractor.js +1 -2355
- package/src/proxy/trace/usage.js +1 -105
|
@@ -6,6 +6,7 @@ const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
|
|
|
6
6
|
const { readMailboxStateFile } = require('../mailbox/state');
|
|
7
7
|
const { buildEnvelope } = require('../envelope');
|
|
8
8
|
const crypto = require('crypto');
|
|
9
|
+
const { acquireCanonicalIdentityLock } = require('../../canonicalIdentityLock');
|
|
9
10
|
const {
|
|
10
11
|
hubFetch,
|
|
11
12
|
hubUnreachableBackoffMs,
|
|
@@ -146,6 +147,175 @@ function _readLegacyNodeId() {
|
|
|
146
147
|
return null;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
const CANONICAL_CREDENTIAL_SIBLINGS = Object.freeze([
|
|
151
|
+
'node_secret',
|
|
152
|
+
'node_secret_version',
|
|
153
|
+
'node_secret_source',
|
|
154
|
+
'node_secret_env_suppressed',
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
function _readValidNodeIdFile(file) {
|
|
158
|
+
try {
|
|
159
|
+
if (!fs.existsSync(file)) return null;
|
|
160
|
+
const value = fs.readFileSync(file, 'utf8').trim();
|
|
161
|
+
return NODE_ID_RE.test(value) ? value : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _snapshotCanonicalCredentials(nodeIdFile) {
|
|
168
|
+
const snapshot = new Map();
|
|
169
|
+
const dir = path.dirname(nodeIdFile);
|
|
170
|
+
for (const name of CANONICAL_CREDENTIAL_SIBLINGS) {
|
|
171
|
+
const file = path.join(dir, name);
|
|
172
|
+
try {
|
|
173
|
+
snapshot.set(file, fs.readFileSync(file));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (e && e.code === 'ENOENT') {
|
|
176
|
+
snapshot.set(file, null);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return snapshot;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _credentialSnapshotHasData(snapshot) {
|
|
186
|
+
if (!(snapshot instanceof Map)) return false;
|
|
187
|
+
for (const content of snapshot.values()) {
|
|
188
|
+
if (content !== null) return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _clearCanonicalCredentials(nodeIdFile) {
|
|
194
|
+
const dir = path.dirname(nodeIdFile);
|
|
195
|
+
let cleared = true;
|
|
196
|
+
for (const name of CANONICAL_CREDENTIAL_SIBLINGS) {
|
|
197
|
+
const file = path.join(dir, name);
|
|
198
|
+
try {
|
|
199
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
200
|
+
} catch {
|
|
201
|
+
cleared = false;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(file)) cleared = false;
|
|
205
|
+
} catch {
|
|
206
|
+
cleared = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return cleared;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _preparePrivateFile(file, content) {
|
|
213
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
214
|
+
let fd = null;
|
|
215
|
+
try {
|
|
216
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
217
|
+
fs.writeFileSync(fd, content);
|
|
218
|
+
fs.fsyncSync(fd);
|
|
219
|
+
fs.closeSync(fd);
|
|
220
|
+
fd = null;
|
|
221
|
+
return tmp;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (fd !== null) {
|
|
224
|
+
try { fs.closeSync(fd); } catch { /* preserve the original write error */ }
|
|
225
|
+
}
|
|
226
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _commitPreparedFile(tmp, file) {
|
|
232
|
+
if (process.platform === 'win32') {
|
|
233
|
+
try { fs.unlinkSync(file); } catch (e) {
|
|
234
|
+
if (!e || e.code !== 'ENOENT') throw e;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
fs.renameSync(tmp, file);
|
|
238
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort on Windows */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _restoreCanonicalCredentials(snapshot) {
|
|
242
|
+
if (!(snapshot instanceof Map)) return false;
|
|
243
|
+
for (const [file, content] of snapshot.entries()) {
|
|
244
|
+
try {
|
|
245
|
+
if (content === null) {
|
|
246
|
+
try { fs.unlinkSync(file); } catch (e) {
|
|
247
|
+
if (!e || e.code !== 'ENOENT') return false;
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const tmp = _preparePrivateFile(file, content);
|
|
251
|
+
try {
|
|
252
|
+
_commitPreparedFile(tmp, file);
|
|
253
|
+
} finally {
|
|
254
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const [file, content] of snapshot.entries()) {
|
|
262
|
+
try {
|
|
263
|
+
if (content === null) {
|
|
264
|
+
if (fs.existsSync(file)) return false;
|
|
265
|
+
} else if (!fs.readFileSync(file).equals(content)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function _replaceNodeIdFile(file, id) {
|
|
276
|
+
const expected = Buffer.from(id, 'utf8');
|
|
277
|
+
const tmp = _preparePrivateFile(file, expected);
|
|
278
|
+
try {
|
|
279
|
+
_commitPreparedFile(tmp, file);
|
|
280
|
+
} finally {
|
|
281
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
282
|
+
}
|
|
283
|
+
if (!fs.readFileSync(file).equals(expected)) {
|
|
284
|
+
throw new Error('node_id replacement verification failed');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _failClosedCanonicalTuple(nodeIdFile) {
|
|
289
|
+
const credentialsCleared = _clearCanonicalCredentials(nodeIdFile);
|
|
290
|
+
if (!credentialsCleared) {
|
|
291
|
+
// Keep an invalid owner marker in place when an orphan credential cannot
|
|
292
|
+
// be removed. A missing node_id would let a later exclusive claim turn
|
|
293
|
+
// the orphan into a readable credential for an unrelated node.
|
|
294
|
+
try { _replaceNodeIdFile(nodeIdFile, 'invalid'); } catch { /* verified below */ }
|
|
295
|
+
try {
|
|
296
|
+
return _readValidNodeIdFile(nodeIdFile) === null && fs.existsSync(nodeIdFile);
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try { fs.unlinkSync(nodeIdFile); } catch (e) {
|
|
302
|
+
if (!e || e.code !== 'ENOENT') {
|
|
303
|
+
try { _replaceNodeIdFile(nodeIdFile, 'invalid'); } catch { /* verified below */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return _readValidNodeIdFile(nodeIdFile) === null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _rollbackCanonicalNodeTransition(nodeIdFile, previousId, snapshot) {
|
|
310
|
+
try { _replaceNodeIdFile(nodeIdFile, previousId); } catch { /* fail closed below */ }
|
|
311
|
+
if (_readValidNodeIdFile(nodeIdFile) === previousId) {
|
|
312
|
+
if (_restoreCanonicalCredentials(snapshot)) return true;
|
|
313
|
+
}
|
|
314
|
+
// Without an exact A snapshot, neither A nor B may remain claimable.
|
|
315
|
+
_failClosedCanonicalTuple(nodeIdFile);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
149
319
|
// Mirror of src/gep/a2aProtocol.js `_persistNodeId`. Pure-proxy daemons
|
|
150
320
|
// (EVOMAP_PROXY=1, no a2aProtocol heartbeat thread) mint their own
|
|
151
321
|
// node_id and ONLY persist it to MailboxStore state.json. The legacy
|
|
@@ -177,25 +347,31 @@ function _readLegacyNodeId() {
|
|
|
177
347
|
// MailboxStore state.json (`evolver reset-local-secret`).
|
|
178
348
|
function _persistLegacyNodeId(id) {
|
|
179
349
|
if (!id || !NODE_ID_RE.test(id)) return;
|
|
350
|
+
const canonicalTarget = getEvomapPath('node_id');
|
|
180
351
|
const targets = [
|
|
181
|
-
|
|
352
|
+
canonicalTarget,
|
|
182
353
|
path.resolve(__dirname, '..', '..', '..', '.evomap_node_id'),
|
|
183
354
|
];
|
|
184
355
|
// Try targets in order until one succeeds, matching the read order in
|
|
185
356
|
// _readLegacyNodeId. We only need ONE persistent copy; once the home
|
|
186
357
|
// path takes the write, the install-root path is unused.
|
|
187
358
|
for (const file of targets) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
359
|
+
let prepared = null;
|
|
360
|
+
let releaseCanonicalLock = null;
|
|
361
|
+
if (file === canonicalTarget) {
|
|
191
362
|
try {
|
|
192
|
-
|
|
193
|
-
const existing = fs.readFileSync(file, 'utf8').trim();
|
|
194
|
-
if (existing === id) return;
|
|
195
|
-
}
|
|
363
|
+
releaseCanonicalLock = acquireCanonicalIdentityLock(file);
|
|
196
364
|
} catch {
|
|
197
|
-
//
|
|
365
|
+
// Never fall through to the install-local identity while another
|
|
366
|
+
// process may be mutating the canonical tuple.
|
|
367
|
+
return;
|
|
198
368
|
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
// Skip if the file already matches — common steady-state path,
|
|
372
|
+
// saves a syscall storm under heartbeat backoff doubling.
|
|
373
|
+
const existing = _readValidNodeIdFile(file);
|
|
374
|
+
if (existing === id) return;
|
|
199
375
|
const dir = path.dirname(file);
|
|
200
376
|
try {
|
|
201
377
|
if (!fs.existsSync(dir)) {
|
|
@@ -207,19 +383,78 @@ function _persistLegacyNodeId(id) {
|
|
|
207
383
|
// may still work.
|
|
208
384
|
continue;
|
|
209
385
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
386
|
+
const isCanonicalTransition = file === canonicalTarget
|
|
387
|
+
&& existing !== id;
|
|
388
|
+
const credentialSnapshot = isCanonicalTransition
|
|
389
|
+
? _snapshotCanonicalCredentials(file)
|
|
390
|
+
: null;
|
|
391
|
+
if (isCanonicalTransition && !credentialSnapshot) return;
|
|
392
|
+
const hasCanonicalCredentials = _credentialSnapshotHasData(credentialSnapshot);
|
|
393
|
+
const requiresCredentialInvalidation = isCanonicalTransition
|
|
394
|
+
&& (Boolean(existing) || hasCanonicalCredentials);
|
|
395
|
+
const canRestorePreviousOwner = Boolean(existing);
|
|
396
|
+
|
|
397
|
+
// Prepare the exact bytes before invalidating A's credential tuple.
|
|
398
|
+
// The same file is committed after clear-before; it is not rewritten.
|
|
399
|
+
prepared = _preparePrivateFile(file, Buffer.from(id, 'utf8'));
|
|
400
|
+
if (requiresCredentialInvalidation && !_clearCanonicalCredentials(file)) {
|
|
401
|
+
try { fs.unlinkSync(prepared); } catch { /* best-effort cleanup */ }
|
|
402
|
+
prepared = null;
|
|
403
|
+
const recovered = canRestorePreviousOwner
|
|
404
|
+
? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
|
|
405
|
+
: _failClosedCanonicalTuple(file);
|
|
406
|
+
if (!recovered) {
|
|
407
|
+
const err = new Error('failed to restore canonical identity after credential invalidation failure');
|
|
408
|
+
err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
_commitPreparedFile(prepared, file);
|
|
416
|
+
prepared = null;
|
|
417
|
+
if (_readValidNodeIdFile(file) !== id) {
|
|
418
|
+
throw new Error('node_id replacement verification failed');
|
|
419
|
+
}
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (requiresCredentialInvalidation) {
|
|
422
|
+
const recovered = canRestorePreviousOwner
|
|
423
|
+
? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
|
|
424
|
+
: _failClosedCanonicalTuple(file);
|
|
425
|
+
if (!recovered) {
|
|
426
|
+
const err = new Error('failed to restore canonical identity after node_id replacement failure');
|
|
427
|
+
err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
throw e;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (requiresCredentialInvalidation && !_clearCanonicalCredentials(file)) {
|
|
436
|
+
const recovered = canRestorePreviousOwner
|
|
437
|
+
? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
|
|
438
|
+
: _failClosedCanonicalTuple(file);
|
|
439
|
+
if (!recovered) {
|
|
440
|
+
const err = new Error('failed to invalidate canonical credentials after node_id transition');
|
|
441
|
+
err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
217
446
|
return;
|
|
218
|
-
} catch {
|
|
447
|
+
} catch (e) {
|
|
448
|
+
if (e && e.code === 'CANONICAL_IDENTITY_TRANSITION_UNSAFE') throw e;
|
|
219
449
|
// Best-effort: continue to the next candidate. If both fail (no
|
|
220
450
|
// home, no writable install root) we accept the legacy file is
|
|
221
451
|
// unavailable — the proxy will still function, it just cannot
|
|
222
452
|
// unify state-file suffixes with a co-resident a2aProtocol path.
|
|
453
|
+
} finally {
|
|
454
|
+
if (prepared) {
|
|
455
|
+
try { fs.unlinkSync(prepared); } catch { /* best-effort cleanup */ }
|
|
456
|
+
}
|
|
457
|
+
if (releaseCanonicalLock) releaseCanonicalLock();
|
|
223
458
|
}
|
|
224
459
|
}
|
|
225
460
|
}
|
|
@@ -445,11 +680,12 @@ function normalizeNodeSecretEnvSuppression(value) {
|
|
|
445
680
|
}
|
|
446
681
|
|
|
447
682
|
class LifecycleManager {
|
|
448
|
-
constructor({ hubUrl, store, logger, getTaskMeta } = {}) {
|
|
683
|
+
constructor({ hubUrl, store, logger, getTaskMeta, onWake } = {}) {
|
|
449
684
|
this.hubUrl = (hubUrl || process.env.A2A_HUB_URL || '').replace(/\/+$/, '');
|
|
450
685
|
this.store = store;
|
|
451
686
|
this.logger = logger || console;
|
|
452
687
|
this.getTaskMeta = getTaskMeta || null;
|
|
688
|
+
this.onWake = typeof onWake === 'function' ? onWake : null;
|
|
453
689
|
this._heartbeatTimer = null;
|
|
454
690
|
this._running = false;
|
|
455
691
|
this._startedAt = null;
|
|
@@ -468,6 +704,7 @@ class LifecycleManager {
|
|
|
468
704
|
: null);
|
|
469
705
|
this._envSuppressionClearedForSecret = null;
|
|
470
706
|
this._suppressEnvSecret = Boolean(this._envSecretSuppressionMarker && getEnvNodeSecret());
|
|
707
|
+
this._deliveryIdentityListeners = new Set();
|
|
471
708
|
|
|
472
709
|
// H4 fix: persist the legacy node_id file as soon as the in-memory
|
|
473
710
|
// node_id is known, NOT only after a successful hello(). The original
|
|
@@ -537,6 +774,20 @@ class LifecycleManager {
|
|
|
537
774
|
return validStoreSecret ? storeVersion : null;
|
|
538
775
|
}
|
|
539
776
|
|
|
777
|
+
onDeliveryIdentityChange(listener) {
|
|
778
|
+
if (typeof listener !== 'function') return () => {};
|
|
779
|
+
this._deliveryIdentityListeners.add(listener);
|
|
780
|
+
return () => this._deliveryIdentityListeners.delete(listener);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
_notifyDeliveryIdentityChange() {
|
|
784
|
+
for (const listener of Array.from(this._deliveryIdentityListeners)) {
|
|
785
|
+
try { listener(); } catch {
|
|
786
|
+
this.logger.warn?.('[lifecycle] delivery identity listener failed');
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
540
791
|
/**
|
|
541
792
|
* Resolve the active node_secret with conflict reconciliation between the
|
|
542
793
|
* persistent MailboxStore and `process.env.A2A_NODE_SECRET`.
|
|
@@ -881,6 +1132,7 @@ class LifecycleManager {
|
|
|
881
1132
|
}
|
|
882
1133
|
|
|
883
1134
|
this.store.setState('node_id', nodeId);
|
|
1135
|
+
this._notifyDeliveryIdentityChange();
|
|
884
1136
|
// Unify proxy node_id with the legacy GEP file. Without this, the
|
|
885
1137
|
// proxy-only fast path (EVOMAP_PROXY=1) never seeds
|
|
886
1138
|
// ~/.evomap/node_id and `_shortNodeIdForStatePath` in a2aProtocol
|
|
@@ -1054,6 +1306,7 @@ class LifecycleManager {
|
|
|
1054
1306
|
envSuppressed: this.store.getState('node_secret_env_suppressed') || '',
|
|
1055
1307
|
});
|
|
1056
1308
|
this.logger.warn('[lifecycle] local in-memory node_secret is stale; preserving newer disk hub-rotated secret');
|
|
1309
|
+
this._notifyDeliveryIdentityChange();
|
|
1057
1310
|
return;
|
|
1058
1311
|
} catch (err) {
|
|
1059
1312
|
const reason = err && (err.code || err.name) ? (err.code || err.name) : 'error';
|
|
@@ -1074,6 +1327,7 @@ class LifecycleManager {
|
|
|
1074
1327
|
// Suppress only the exact env secret that was just proven stale. If no
|
|
1075
1328
|
// env secret is present, do not leave a marker that blocks a future reset.
|
|
1076
1329
|
this._markCurrentEnvSecretSuppressed();
|
|
1330
|
+
this._notifyDeliveryIdentityChange();
|
|
1077
1331
|
}
|
|
1078
1332
|
|
|
1079
1333
|
_emitManualResetNeeded() {
|
|
@@ -1436,6 +1690,13 @@ class LifecycleManager {
|
|
|
1436
1690
|
} catch (_) { /* logger broken; non-fatal */ }
|
|
1437
1691
|
}
|
|
1438
1692
|
this.pokeHeartbeatLoop();
|
|
1693
|
+
if (this.onWake) {
|
|
1694
|
+
try {
|
|
1695
|
+
this.onWake();
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
this.logger.warn?.(`[lifecycle] wake recovery callback failed: ${err && err.message || err}`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1439
1700
|
}
|
|
1440
1701
|
} catch (err) {
|
|
1441
1702
|
try { this.logger.error(`[lifecycle] drift detector threw: ${err && err.message}`); }
|
|
@@ -141,6 +141,39 @@ function replaceStateFile(stateFile, state) {
|
|
|
141
141
|
bestEffortChmod(stateFile, PRIVATE_FILE_MODE);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function mergeMailboxState(diskState, nextState, updatedKeys) {
|
|
145
|
+
const next = isPlainState(nextState) ? nextState : {};
|
|
146
|
+
const disk = isPlainState(diskState) ? diskState : null;
|
|
147
|
+
const hasDiskState = Boolean(disk);
|
|
148
|
+
const merged = hasDiskState ? { ...disk } : {};
|
|
149
|
+
const updatedSet = updatedKeys ? new Set(Array.from(updatedKeys)) : null;
|
|
150
|
+
const keys = updatedSet ? Array.from(updatedSet) : Object.keys(next);
|
|
151
|
+
const touchesNodeSecretTuple = keys.some((key) => MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key));
|
|
152
|
+
const preserveDiskNodeSecretTuple = touchesNodeSecretTuple
|
|
153
|
+
&& hasDiskState
|
|
154
|
+
&& isHubRotatedNodeSecretState(disk)
|
|
155
|
+
&& !isFullNodeSecretTupleUpdate(updatedSet);
|
|
156
|
+
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
if (MAILBOX_NODE_SECRET_STATE_KEY_SET.has(key) && hasDiskState && (!updatedSet || !updatedSet.has(key))) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key) &&
|
|
163
|
+
preserveDiskNodeSecretTuple &&
|
|
164
|
+
!canApplyPartialNodeSecretTupleWrite(key, disk, next)
|
|
165
|
+
) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!Object.prototype.hasOwnProperty.call(next, key)) {
|
|
169
|
+
delete merged[key];
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
merged[key] = next[key];
|
|
173
|
+
}
|
|
174
|
+
return merged;
|
|
175
|
+
}
|
|
176
|
+
|
|
144
177
|
/**
|
|
145
178
|
* Merge a partial state write with the latest on-disk state.
|
|
146
179
|
*
|
|
@@ -157,36 +190,8 @@ function replaceStateFile(stateFile, state) {
|
|
|
157
190
|
function writeMergedMailboxStateFile(stateFile, nextState, updatedKeys) {
|
|
158
191
|
const releaseLock = acquireStateFileLock(stateFile);
|
|
159
192
|
try {
|
|
160
|
-
const next = isPlainState(nextState) ? nextState : {};
|
|
161
193
|
const disk = readMailboxStateFile(stateFile);
|
|
162
|
-
const
|
|
163
|
-
const merged = hasDiskState ? { ...disk } : {};
|
|
164
|
-
const updatedSet = updatedKeys ? new Set(Array.from(updatedKeys)) : null;
|
|
165
|
-
const keys = updatedSet ? Array.from(updatedSet) : Object.keys(next);
|
|
166
|
-
const touchesNodeSecretTuple = keys.some((key) => MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key));
|
|
167
|
-
const preserveDiskNodeSecretTuple = touchesNodeSecretTuple
|
|
168
|
-
&& hasDiskState
|
|
169
|
-
&& isHubRotatedNodeSecretState(disk)
|
|
170
|
-
&& !isFullNodeSecretTupleUpdate(updatedSet);
|
|
171
|
-
|
|
172
|
-
for (const key of keys) {
|
|
173
|
-
if (MAILBOX_NODE_SECRET_STATE_KEY_SET.has(key) && hasDiskState && (!updatedSet || !updatedSet.has(key))) {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
if (
|
|
177
|
-
MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key) &&
|
|
178
|
-
preserveDiskNodeSecretTuple &&
|
|
179
|
-
!canApplyPartialNodeSecretTupleWrite(key, disk, next)
|
|
180
|
-
) {
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (!Object.prototype.hasOwnProperty.call(next, key)) {
|
|
184
|
-
delete merged[key];
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
merged[key] = next[key];
|
|
188
|
-
}
|
|
189
|
-
|
|
194
|
+
const merged = mergeMailboxState(disk, nextState, updatedKeys);
|
|
190
195
|
replaceStateFile(stateFile, merged);
|
|
191
196
|
return merged;
|
|
192
197
|
} finally {
|
|
@@ -194,6 +199,31 @@ function writeMergedMailboxStateFile(stateFile, nextState, updatedKeys) {
|
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Conditionally update mailbox state while holding the state-file lock.
|
|
204
|
+
* The callback returns `{ nextState, updatedKeys }`, or null to fail closed
|
|
205
|
+
* without writing. This keeps owner checks and legacy node_id binding in the
|
|
206
|
+
* same critical section as the secret tuple mutation.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} stateFile
|
|
209
|
+
* @param {(state: Record<string, unknown>|null) => {nextState: Record<string, unknown>, updatedKeys: Iterable<string>}|null} buildUpdate
|
|
210
|
+
* @returns {{updated: boolean, state: Record<string, unknown>|null}}
|
|
211
|
+
*/
|
|
212
|
+
function updateMergedMailboxStateFile(stateFile, buildUpdate) {
|
|
213
|
+
if (typeof buildUpdate !== 'function') throw new TypeError('buildUpdate must be a function');
|
|
214
|
+
const releaseLock = acquireStateFileLock(stateFile);
|
|
215
|
+
try {
|
|
216
|
+
const disk = readMailboxStateFile(stateFile);
|
|
217
|
+
const update = buildUpdate(isPlainState(disk) ? { ...disk } : null);
|
|
218
|
+
if (!update) return { updated: false, state: disk };
|
|
219
|
+
const merged = mergeMailboxState(disk, update.nextState, update.updatedKeys);
|
|
220
|
+
replaceStateFile(stateFile, merged);
|
|
221
|
+
return { updated: true, state: merged };
|
|
222
|
+
} finally {
|
|
223
|
+
releaseLock();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
module.exports = {
|
|
198
228
|
PRIVATE_DIR_MODE,
|
|
199
229
|
PRIVATE_FILE_MODE,
|
|
@@ -204,4 +234,5 @@ module.exports = {
|
|
|
204
234
|
readMailboxStateFile,
|
|
205
235
|
isHubRotatedNodeSecretState,
|
|
206
236
|
writeMergedMailboxStateFile,
|
|
237
|
+
updateMergedMailboxStateFile,
|
|
207
238
|
};
|