dsh-autorestart 0.8.4 → 0.8.6
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/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
package/lib/restart-phase.js
CHANGED
|
@@ -163,6 +163,7 @@ export function describeRestartPhase({
|
|
|
163
163
|
progress = null,
|
|
164
164
|
blocking = null,
|
|
165
165
|
snapshotBytes = null,
|
|
166
|
+
reason = null,
|
|
166
167
|
now = Date.now(),
|
|
167
168
|
} = {}) {
|
|
168
169
|
const stage = isRestartPhase(phase) ? phase : 'idle';
|
|
@@ -299,6 +300,10 @@ export function describeRestartPhase({
|
|
|
299
300
|
snapshotBytes: snapshotBytes === null || snapshotBytes === undefined || snapshotBytes === ''
|
|
300
301
|
? null
|
|
301
302
|
: nonNegative(snapshotBytes),
|
|
303
|
+
// WHY the stage is what it is: "deferred: conversation-active" says the
|
|
304
|
+
// restart went back to waiting, which is what a reader needs to tell a
|
|
305
|
+
// postponed restart from a running one.
|
|
306
|
+
reason: typeof reason === 'string' && reason !== '' ? reason : null,
|
|
302
307
|
text,
|
|
303
308
|
};
|
|
304
309
|
}
|
package/lib/restart-verdict.js
CHANGED
|
@@ -230,6 +230,385 @@ export function evaluateRestartChecks(checks) {
|
|
|
230
230
|
return { ok: failed.length === 0 && list.length > 0, failed, passed }
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/** The source that certifies a backup copy when the four checks all ran and passed. */
|
|
234
|
+
export const BOOT_CHECK_SOURCE = 'full-checks'
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* THE CRITERION THAT PROVED THE COPY — the second half of the certification.
|
|
238
|
+
*
|
|
239
|
+
* WHY IT LIVES IN THIS MODULE AND NOT ONLY IN `lib/backup.js`: both deciding
|
|
240
|
+
* functions here already answer "may this boot certify the copy it came from",
|
|
241
|
+
* and they answer it with ONE criterion — the configuration fingerprints were
|
|
242
|
+
* compared and matched. That answer is now part of the result, so a caller
|
|
243
|
+
* cannot record "certified by full-checks" while forgetting WHAT was checked:
|
|
244
|
+
* the label alone is exactly what made the two broken 71B/70B configurations on
|
|
245
|
+
* the hosted instance indistinguishable from a proven copy (see
|
|
246
|
+
* `CERTIFICATION_EVIDENCE` in lib/backup.js, which is where the field is stored).
|
|
247
|
+
*
|
|
248
|
+
* THE VALUE IS PINNED TO `backup.js` BY A TEST (test/certification-evidence.test.mjs):
|
|
249
|
+
* this module deliberately imports no local module (it is the dependency-free
|
|
250
|
+
* record layer), so the two spellings are kept identical by a drift guard rather
|
|
251
|
+
* than by an import.
|
|
252
|
+
*/
|
|
253
|
+
export const FINGERPRINT_MATCH_EVIDENCE = 'fingerprint-match'
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The source that certifies a copy in **command mode** (the health probe answered).
|
|
257
|
+
*
|
|
258
|
+
* WHY IT IS NAMED HERE, next to the other source: the two certification paths
|
|
259
|
+
* (app mode -> four checks, command mode -> health probe) ask the SAME content
|
|
260
|
+
* question, so both answers belong in one module. Keeping the label next to the
|
|
261
|
+
* rule is what stops a future caller from inventing a third, laxer rule.
|
|
262
|
+
*/
|
|
263
|
+
export const PROBE_CHECK_SOURCE = 'command-probe'
|
|
264
|
+
|
|
265
|
+
/** How many characters of a fingerprint a log line may show. */
|
|
266
|
+
export const FINGERPRINT_LOG_CHARS = 12
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The first characters of a fingerprint, for a log line or a reason.
|
|
270
|
+
*
|
|
271
|
+
* WHY SHORT: the point of logging both values is "which one differs from which",
|
|
272
|
+
* and twelve characters answer that. The whole 64-character digest in every log
|
|
273
|
+
* line makes the news unreadable and floods the log of every boot.
|
|
274
|
+
*
|
|
275
|
+
* `(none)` (not an empty string) when there is nothing to show: "this side has
|
|
276
|
+
* no fingerprint" is exactly the fact the operator has to see.
|
|
277
|
+
*
|
|
278
|
+
* @param {unknown} value
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
export function shortFingerprint(value) {
|
|
282
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
283
|
+
if (text === '') return '(none)'
|
|
284
|
+
return text.slice(0, FINGERPRINT_LOG_CHARS)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The five outcomes of comparing the two configuration fingerprints. */
|
|
288
|
+
export const FINGERPRINT_STATES = Object.freeze({
|
|
289
|
+
/** Both sides name the same content: this boot ran on the snapshot content. */
|
|
290
|
+
EQUAL: 'equal',
|
|
291
|
+
/** Both exist and differ: the configuration was changed between the two boots. */
|
|
292
|
+
MISMATCH: 'mismatch',
|
|
293
|
+
/** The snapshot has no fingerprint (taken before fingerprints existed). */
|
|
294
|
+
SNAPSHOT_MISSING: 'snapshot-missing',
|
|
295
|
+
/** This boot could not fingerprint its own configuration. */
|
|
296
|
+
CURRENT_MISSING: 'current-missing',
|
|
297
|
+
/** Neither side has one: nothing to compare at all. */
|
|
298
|
+
BOTH_MISSING: 'both-missing',
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
/** A fingerprint is a non-empty string; everything else is "not recorded". */
|
|
302
|
+
function normalizeFingerprint(raw) {
|
|
303
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
304
|
+
return text === '' ? null : text
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* COMPARE the two configuration fingerprints — the hard physical fact.
|
|
309
|
+
*
|
|
310
|
+
* WHY A COMPARISON AND NOT A FLAG: `snapshotFingerprint` is the content the
|
|
311
|
+
* snapshot holds; `currentFingerprint` is the content the host is running NOW.
|
|
312
|
+
* If they differ, the configuration was written to between the two boots — by a
|
|
313
|
+
* restore, by a hand edit, or by anything else on the machine — and a boot that
|
|
314
|
+
* succeeded on THAT content says nothing about the content in the snapshot.
|
|
315
|
+
* Nothing about "who touched what" is needed for this answer, which is the whole
|
|
316
|
+
* reason it is trustworthy.
|
|
317
|
+
*
|
|
318
|
+
* @param {{ snapshotFingerprint?: unknown, currentFingerprint?: unknown }} [input]
|
|
319
|
+
* @returns {{
|
|
320
|
+
* state: string,
|
|
321
|
+
* equal: boolean,
|
|
322
|
+
* snapshotFingerprint: string|null,
|
|
323
|
+
* currentFingerprint: string|null,
|
|
324
|
+
* snapshotShort: string,
|
|
325
|
+
* currentShort: string,
|
|
326
|
+
* reason: string|null,
|
|
327
|
+
* }}
|
|
328
|
+
*/
|
|
329
|
+
export function compareConfigurationFingerprints({ snapshotFingerprint = null, currentFingerprint = null } = {}) {
|
|
330
|
+
const snapshot = normalizeFingerprint(snapshotFingerprint)
|
|
331
|
+
const current = normalizeFingerprint(currentFingerprint)
|
|
332
|
+
let state = FINGERPRINT_STATES.EQUAL
|
|
333
|
+
if (snapshot === null && current === null) state = FINGERPRINT_STATES.BOTH_MISSING
|
|
334
|
+
else if (snapshot === null) state = FINGERPRINT_STATES.SNAPSHOT_MISSING
|
|
335
|
+
else if (current === null) state = FINGERPRINT_STATES.CURRENT_MISSING
|
|
336
|
+
else if (snapshot !== current) state = FINGERPRINT_STATES.MISMATCH
|
|
337
|
+
const snapshotShort = shortFingerprint(snapshot)
|
|
338
|
+
const currentShort = shortFingerprint(current)
|
|
339
|
+
let reason = null
|
|
340
|
+
if (state === FINGERPRINT_STATES.MISMATCH) {
|
|
341
|
+
// THIS SENTENCE IS THE WHOLE POINT OF THE RULE, so it names the possible
|
|
342
|
+
// causes instead of saying "did not pass": the reader has to be able to tell
|
|
343
|
+
// "the configuration moved under us" apart from "a check went wrong".
|
|
344
|
+
reason = 'the configuration was changed between the two boots (a restore, a manual edit or another writer): '
|
|
345
|
+
+ 'this copy was never proven (the snapshot holds ' + snapshotShort + ', this boot runs ' + currentShort + ')'
|
|
346
|
+
} else if (state === FINGERPRINT_STATES.SNAPSHOT_MISSING) {
|
|
347
|
+
reason = 'the snapshot has no contentFingerprint (it was taken before fingerprints existed), '
|
|
348
|
+
+ 'so nothing shows that this boot ran on the content it holds (this boot runs ' + currentShort + ')'
|
|
349
|
+
} else if (state === FINGERPRINT_STATES.CURRENT_MISSING) {
|
|
350
|
+
reason = 'this boot could not fingerprint its own configuration (the profile directory is unknown or unreadable), '
|
|
351
|
+
+ 'so it cannot be shown to be the content of the snapshot (' + snapshotShort + ')'
|
|
352
|
+
} else if (state === FINGERPRINT_STATES.BOTH_MISSING) {
|
|
353
|
+
reason = 'neither side has a configuration fingerprint: the snapshot was taken before fingerprints existed '
|
|
354
|
+
+ 'and this boot could not fingerprint its configuration'
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
state,
|
|
358
|
+
equal: state === FINGERPRINT_STATES.EQUAL,
|
|
359
|
+
snapshotFingerprint: snapshot,
|
|
360
|
+
currentFingerprint: current,
|
|
361
|
+
snapshotShort,
|
|
362
|
+
currentShort,
|
|
363
|
+
reason,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* CAN THIS BOOT CERTIFY THE COPY IT CAME FROM? — the decision, not the label.
|
|
369
|
+
*
|
|
370
|
+
* WHY THIS LIVES HERE, next to the checks themselves: until now the four checks
|
|
371
|
+
* only decided the verdict of "this restart" (`ok` / `failed`); the question
|
|
372
|
+
* "may the copy that produced this boot now be called safe?" was answered
|
|
373
|
+
* somewhere else — and the answer used to be "yes, unconditionally". The two
|
|
374
|
+
* questions are not the same one, so the rule is spelled out here and the caller
|
|
375
|
+
* cannot answer it by accident.
|
|
376
|
+
*
|
|
377
|
+
* THE RULE (2026-11-01, strict mode phase 3 — fingerprint first):
|
|
378
|
+
*
|
|
379
|
+
* ① THE HARD GATE: the snapshot's `contentFingerprint` and the fingerprint of
|
|
380
|
+
* the configuration that is running NOW must BOTH exist and be EQUAL.
|
|
381
|
+
* A mismatch means the configuration was written to between the two boots
|
|
382
|
+
* (restore, hand edit, another writer), so this boot proved the OTHER
|
|
383
|
+
* content and the snapshot stays unproven. A missing fingerprint on either
|
|
384
|
+
* side is not a "probably fine": it is no proof at all, so it refuses too.
|
|
385
|
+
* ② then, and only then, ALL FOUR checks (`listener` / `routes` / `plugin` /
|
|
386
|
+
* `recovery`) must have RUN and passed. A check that did not run is NOT a
|
|
387
|
+
* pass: a boot that skipped them (`resumeOnBoot` off, a probe that never
|
|
388
|
+
* reached the checks) proves nothing about the configuration.
|
|
389
|
+
* ③ a check that ran and FAILED blocks the certification, and so does any
|
|
390
|
+
* OTHER check that failed — "the boot is clean" is not a subset test.
|
|
391
|
+
*
|
|
392
|
+
* WHY THE ORDER IS FINGERPRINT-FIRST: "the boot came up" and "the snapshot holds
|
|
393
|
+
* good content" have NO causal link. If the snapshot held a broken configuration,
|
|
394
|
+
* a successful boot is evidence that the configuration was RESTORED — the exact
|
|
395
|
+
* opposite of evidence that the broken copy is safe. The only thing that ties this
|
|
396
|
+
* boot to that copy is content identity, so that is what gets checked first.
|
|
397
|
+
*
|
|
398
|
+
* 为什么这条判据用「内容指纹」而不是 `intervention`(真机证据,2026-11-01):
|
|
399
|
+
* 托管实例备份 `2026-11-01T12-04-33-777Z` 被认证成 `certifiedBy=full-checks`、
|
|
400
|
+
* 而它手里的 `config/cordis.patch.yml` 只有 70 字节(正常 691 字节,坏配置);
|
|
401
|
+
* 那份 meta 里的 `intervention` 记的是 `false` —— 可那次宿主其实是先被还原过才起来的。
|
|
402
|
+
* 也就是说「谁介入过」这件事**测不准**(检测不到不等于没发生),抱着它盖章正是
|
|
403
|
+
* 坏配置被认证的根因;而「这次启动跑的配置与快照里那份是不是同一份」是当场能算、
|
|
404
|
+
* 不依赖任何检测的硬事实。所以 `intervention` 继续写进 meta 供事后排查,但**不再
|
|
405
|
+
* 参与这里的判定**:不一致就是不一致,不许退化成「可以盖章,只标一下」。
|
|
406
|
+
*
|
|
407
|
+
* `evaluateRestartChecks` is deliberately NOT reused as the gate: it says `ok`
|
|
408
|
+
* for any non-empty list of passing checks, which is right for the record
|
|
409
|
+
* ("this restart worked") and wrong for certification ("these four things were
|
|
410
|
+
* verified").
|
|
411
|
+
*
|
|
412
|
+
* The failure is never summarised away: `failed` / `missing` / `details` name
|
|
413
|
+
* which of the four did not pass, and `fingerprintState` names which side of the
|
|
414
|
+
* fingerprint comparison was missing or different, so the report can say it.
|
|
415
|
+
*
|
|
416
|
+
* @param {any[]} checks the checks the new instance ran on itself
|
|
417
|
+
* @param {{ snapshotFingerprint?: unknown, currentFingerprint?: unknown }} [fingerprints]
|
|
418
|
+
* `snapshotFingerprint`: the `contentFingerprint` stored in the snapshot's meta.
|
|
419
|
+
* `currentFingerprint`: the fingerprint of the configuration on disk NOW.
|
|
420
|
+
* @returns {{
|
|
421
|
+
* certified: boolean,
|
|
422
|
+
* certifiedBy: string,
|
|
423
|
+
* passed: string[],
|
|
424
|
+
* failed: string[],
|
|
425
|
+
* missing: string[],
|
|
426
|
+
* extraFailed: string[],
|
|
427
|
+
* details: Array<{ name: string, ok: boolean, detail: string|null }>,
|
|
428
|
+
* snapshotFingerprint: string|null,
|
|
429
|
+
* currentFingerprint: string|null,
|
|
430
|
+
* fingerprintState: string,
|
|
431
|
+
* fingerprintsEqual: boolean,
|
|
432
|
+
* reason: string,
|
|
433
|
+
* }}
|
|
434
|
+
*/
|
|
435
|
+
export function decideBootCertification(checks, { snapshotFingerprint = null, currentFingerprint = null } = {}) {
|
|
436
|
+
const list = Array.isArray(checks) ? checks : []
|
|
437
|
+
// One entry per NAME: if the same check is reported twice and one of the two
|
|
438
|
+
// failed, the failure wins — a report that disagrees with itself is not a pass.
|
|
439
|
+
const byName = new Map()
|
|
440
|
+
for (const raw of list) {
|
|
441
|
+
const check = normalizeCheck(raw)
|
|
442
|
+
if (check.name === 'unknown') continue
|
|
443
|
+
const previous = byName.get(check.name)
|
|
444
|
+
if (previous === undefined) {
|
|
445
|
+
byName.set(check.name, check)
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
byName.set(check.name, {
|
|
449
|
+
name: check.name,
|
|
450
|
+
ok: previous.ok === true && check.ok === true,
|
|
451
|
+
detail: check.ok === true ? previous.detail : check.detail,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
const passed = []
|
|
455
|
+
const failed = []
|
|
456
|
+
const missing = []
|
|
457
|
+
const details = []
|
|
458
|
+
for (const name of RESTART_CHECK_NAMES) {
|
|
459
|
+
const found = byName.get(name)
|
|
460
|
+
if (found === undefined) {
|
|
461
|
+
missing.push(name)
|
|
462
|
+
details.push({ name, ok: false, detail: 'this check did not run' })
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
if (found.ok === true) {
|
|
466
|
+
passed.push(name)
|
|
467
|
+
details.push({ name, ok: true, detail: found.detail })
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
failed.push(name)
|
|
471
|
+
details.push({ name, ok: false, detail: found.detail })
|
|
472
|
+
}
|
|
473
|
+
const extraFailed = []
|
|
474
|
+
for (const [name, check] of byName) {
|
|
475
|
+
if (RESTART_CHECK_NAMES.includes(name)) continue
|
|
476
|
+
if (check.ok === true) continue
|
|
477
|
+
extraFailed.push(name)
|
|
478
|
+
details.push({ name, ok: false, detail: check.detail })
|
|
479
|
+
}
|
|
480
|
+
// THE HARD GATE COMES FIRST (see the docstring): a boot that ran on a
|
|
481
|
+
// DIFFERENT configuration than the snapshot holds proves nothing about the
|
|
482
|
+
// snapshot, whatever the checks said.
|
|
483
|
+
const fingerprints = compareConfigurationFingerprints({ snapshotFingerprint, currentFingerprint })
|
|
484
|
+
const certified = fingerprints.equal === true
|
|
485
|
+
&& missing.length === 0
|
|
486
|
+
&& failed.length === 0
|
|
487
|
+
&& extraFailed.length === 0
|
|
488
|
+
const checkProblems = []
|
|
489
|
+
if (failed.length > 0) checkProblems.push('failed: ' + failed.join(', '))
|
|
490
|
+
if (missing.length > 0) checkProblems.push('did not run: ' + missing.join(', '))
|
|
491
|
+
if (extraFailed.length > 0) checkProblems.push('other check(s) failed: ' + extraFailed.join(', '))
|
|
492
|
+
let reason
|
|
493
|
+
if (certified) {
|
|
494
|
+
reason = 'all four startup checks passed (' + RESTART_CHECK_NAMES.join(', ') + ')'
|
|
495
|
+
+ ', and this boot runs exactly the configuration the snapshot holds (' + fingerprints.snapshotShort + ')'
|
|
496
|
+
} else if (fingerprints.state === FINGERPRINT_STATES.MISMATCH) {
|
|
497
|
+
// The mismatch sentence already ends with "this copy was never proven", so
|
|
498
|
+
// it stands on its own instead of being prefixed a second time. Any check
|
|
499
|
+
// problem is appended: both halves of the news are true.
|
|
500
|
+
reason = [fingerprints.reason, ...checkProblems].join('; ')
|
|
501
|
+
} else {
|
|
502
|
+
const parts = [fingerprints.reason, ...checkProblems]
|
|
503
|
+
.filter((part) => typeof part === 'string' && part !== '')
|
|
504
|
+
reason = 'the copy stays unproven: ' + (parts.length > 0 ? parts.join('; ') : 'no proof was produced')
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
certified,
|
|
508
|
+
// The only source this module may ever certify with. A failed boot cannot
|
|
509
|
+
// hand out `manual-rollback` or a seed: those come from elsewhere.
|
|
510
|
+
certifiedBy: certified ? BOOT_CHECK_SOURCE : 'none',
|
|
511
|
+
// AND THE CRITERION: the fingerprints were compared and matched — this is the
|
|
512
|
+
// ONLY way a boot may certify, so it is the only evidence this path returns.
|
|
513
|
+
// `null` on a refusal, so a caller that ignored `certified` cannot store a
|
|
514
|
+
// stamp that a later reader would trust.
|
|
515
|
+
certificationEvidence: certified ? FINGERPRINT_MATCH_EVIDENCE : null,
|
|
516
|
+
passed,
|
|
517
|
+
failed,
|
|
518
|
+
missing,
|
|
519
|
+
extraFailed,
|
|
520
|
+
details,
|
|
521
|
+
// The comparison travels with the decision so the caller can log WHICH two
|
|
522
|
+
// values were compared (short values) without recomputing anything.
|
|
523
|
+
snapshotFingerprint: fingerprints.snapshotFingerprint,
|
|
524
|
+
currentFingerprint: fingerprints.currentFingerprint,
|
|
525
|
+
fingerprintState: fingerprints.state,
|
|
526
|
+
fingerprintsEqual: fingerprints.equal,
|
|
527
|
+
reason,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* MAY THE **HEALTH PROBE** OF A COMMAND-MODE RESTART CERTIFY THE COPY? — the
|
|
533
|
+
* second path of the same rule.
|
|
534
|
+
*
|
|
535
|
+
* WHY THIS EXISTS (2026-11-01, the second path of the same defect): `c419846`
|
|
536
|
+
* replaced the old criterion ("this restart came up") with "the snapshot's
|
|
537
|
+
* fingerprint EQUALS the fingerprint of the configuration running now" — but only
|
|
538
|
+
* on the app-mode path (`verifyRestartBoot` -> `decideBootCertification`). The
|
|
539
|
+
* command-mode path kept the old shape: as soon as the health probe answered it
|
|
540
|
+
* called `markKnownGood` with `contentFingerprint: contentFingerprintOf(copy)` —
|
|
541
|
+
* a fingerprint of the COPY, never compared with anything. That is the same
|
|
542
|
+
* mistake that produced the copy `2026-11-01T12-04-33-777Z`: a probe answering
|
|
543
|
+
* proves that SOME instance answered a route, and it does NOT prove that the
|
|
544
|
+
* configuration inside the snapshot can boot. The probe may have hit the old
|
|
545
|
+
* instance, and the configuration may have been swapped in between.
|
|
546
|
+
*
|
|
547
|
+
* 中文口径(用户已拍板,2026-11-02 重申):宁可没有可回退的那一份,也不许写错记录。
|
|
548
|
+
* 探活成功 ≠ 重启前那份配置能起来;而「那次启动跑的配置,与副本里那份指纹相等」才是
|
|
549
|
+
* 当场能算、能复查的事实——记录里写的就是这条事实,不再写「已认证 / 可用于回退」。
|
|
550
|
+
*
|
|
551
|
+
* THE RULE — identical in wording to `decideBootCertification`, because the two
|
|
552
|
+
* paths must not be able to disagree:
|
|
553
|
+
*
|
|
554
|
+
* ① both fingerprints exist and are EQUAL -> certify with `command-probe`;
|
|
555
|
+
* ② they differ -> no certification, and the reason says the configuration was
|
|
556
|
+
* changed between the two boots;
|
|
557
|
+
* ③ either side missing -> no certification. No proof beats a guessed one:
|
|
558
|
+
* "we could not compare" is an answer, and it is the refusing one.
|
|
559
|
+
*
|
|
560
|
+
* The caller has ALREADY established that the probe answered; this function only
|
|
561
|
+
* answers the content question, so a future change to the probe cannot quietly
|
|
562
|
+
* loosen the gate.
|
|
563
|
+
*
|
|
564
|
+
* @param {{ snapshotFingerprint?: unknown, currentFingerprint?: unknown }} [input]
|
|
565
|
+
* `snapshotFingerprint`: the `contentFingerprint` recorded in the snapshot's
|
|
566
|
+
* meta.json when the copy was taken (NOT recomputed from the copy later: a copy
|
|
567
|
+
* edited after the fact must not be able to certify itself).
|
|
568
|
+
* `currentFingerprint`: the fingerprint of the profile configuration on disk now.
|
|
569
|
+
* @returns {{
|
|
570
|
+
* certified: boolean,
|
|
571
|
+
* certifiedBy: string,
|
|
572
|
+
* snapshotFingerprint: string|null,
|
|
573
|
+
* currentFingerprint: string|null,
|
|
574
|
+
* fingerprintState: string,
|
|
575
|
+
* fingerprintsEqual: boolean,
|
|
576
|
+
* reason: string,
|
|
577
|
+
* }}
|
|
578
|
+
*/
|
|
579
|
+
export function decideProbeCertification({ snapshotFingerprint = null, currentFingerprint = null } = {}) {
|
|
580
|
+
const fingerprints = compareConfigurationFingerprints({ snapshotFingerprint, currentFingerprint })
|
|
581
|
+
const certified = fingerprints.equal === true
|
|
582
|
+
let reason
|
|
583
|
+
if (certified) {
|
|
584
|
+
reason = 'the health probe answered and this boot runs exactly the configuration the snapshot holds ('
|
|
585
|
+
+ fingerprints.snapshotShort + ')'
|
|
586
|
+
} else if (fingerprints.state === FINGERPRINT_STATES.MISMATCH) {
|
|
587
|
+
// The mismatch sentence already ends with "this copy was never proven", so it
|
|
588
|
+
// stands on its own — the same shape `decideBootCertification` produces.
|
|
589
|
+
reason = fingerprints.reason
|
|
590
|
+
} else {
|
|
591
|
+
// "The probe answered" is deliberately NOT repeated here as a consolation: the
|
|
592
|
+
// operator has to see why that fact does not help.
|
|
593
|
+
reason = 'the copy stays unproven: ' + fingerprints.reason
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
certified,
|
|
597
|
+
// The only source this path may ever certify with. A failed comparison cannot
|
|
598
|
+
// hand out `full-checks`: the four checks do not run in command mode.
|
|
599
|
+
certifiedBy: certified ? PROBE_CHECK_SOURCE : 'none',
|
|
600
|
+
// The criterion, exactly as in `decideBootCertification`: the two fingerprints
|
|
601
|
+
// were compared and matched. `null` when the comparison refused, so no caller
|
|
602
|
+
// can turn a refusal into a criterion-free (and therefore untrustworthy) stamp.
|
|
603
|
+
certificationEvidence: certified ? FINGERPRINT_MATCH_EVIDENCE : null,
|
|
604
|
+
snapshotFingerprint: fingerprints.snapshotFingerprint,
|
|
605
|
+
currentFingerprint: fingerprints.currentFingerprint,
|
|
606
|
+
fingerprintState: fingerprints.state,
|
|
607
|
+
fingerprintsEqual: fingerprints.equal,
|
|
608
|
+
reason,
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
233
612
|
/**
|
|
234
613
|
* Write the verdict into the state directory (atomically: a temporary file
|
|
235
614
|
* plus a rename, so a half-written record can never be read).
|