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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
@@ -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
  }
@@ -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).