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
package/lib/restart.js CHANGED
@@ -15,9 +15,125 @@
15
15
  * ESM only, Node builtins only. No DSH / npm imports.
16
16
  */
17
17
  import { execFileSync, spawn } from 'node:child_process';
18
- import { mkdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from 'node:fs';
18
+ import {
19
+ mkdirSync,
20
+ readFileSync,
21
+ readdirSync,
22
+ readlinkSync,
23
+ renameSync,
24
+ statSync,
25
+ unlinkSync,
26
+ writeFileSync,
27
+ } from 'node:fs';
19
28
  import path from 'node:path';
20
29
 
30
+ /**
31
+ * THE V1 ROLLBACK WATCHER IS A POWERSHELL SCRIPT (2026-11-02).
32
+ *
33
+ * `buildWatchScript` writes PowerShell, so the whole v1 path is a Windows path.
34
+ * Older builds did not say so: they wrote `restart-<n>-watch.ps1` on EVERY
35
+ * platform and then tried to launch it, which on Linux/WSL could only fail —
36
+ * and left the file behind. A real WSL machine had SEVEN of them
37
+ * (restart-4/5/6/7/8/9/16), which is why the release checklist item
38
+ * "leftover files = 0" was failing.
39
+ *
40
+ * This pattern is the only name the plugin ever writes, so it is also the only
41
+ * name the sweep below is allowed to touch.
42
+ */
43
+ export const LEGACY_WATCH_SCRIPT_PATTERN = /^restart-\d+-watch\.ps1$/;
44
+
45
+ /**
46
+ * Does this text look like OUR rollback watcher (and not like a user's file that
47
+ * happens to have the same name)?
48
+ *
49
+ * The check is deliberately narrow: the three markers below all come from
50
+ * `buildWatchScript`, and a file that misses any of them is left alone. A sweep
51
+ * that deletes the wrong file in a rescue path is worse than a leftover.
52
+ *
53
+ * @param {string} text
54
+ * @returns {boolean}
55
+ */
56
+ export function looksLikeWatchScript(text) {
57
+ if (typeof text !== 'string' || text === '') return false;
58
+ return /param\s*\(/.test(text)
59
+ && /Start-Sleep|Wait-Job/.test(text)
60
+ && /_dsh_autorestart|dsh-autorestart/.test(text);
61
+ }
62
+
63
+ /**
64
+ * Remove the watcher scripts the old builds left on a NON-Windows machine.
65
+ *
66
+ * Rules (all of them are "be conservative"):
67
+ * - Windows keeps everything: there those files really are used, and the one
68
+ * that is running right now must not be pulled from under it.
69
+ * - Only `restart-<digits>-watch.ps1` is considered.
70
+ * - The CONTENT must look like our watcher (`looksLikeWatchScript`).
71
+ * - A file modified in the last `minAgeMs` is skipped: a process that just
72
+ * wrote it may still be starting.
73
+ * - Nothing here throws: a failure is returned.
74
+ *
75
+ * @param {string} dir the plugin's state directory
76
+ * @param {{ platform?: string, minAgeMs?: number, now?: number }} [options]
77
+ * @returns {{ skipped: boolean, reason: string|null, removed: string[], kept: string[], scanned: number, error: string|null }}
78
+ */
79
+ export function sweepLegacyWatcherScripts(
80
+ dir,
81
+ { platform = process.platform, minAgeMs = 60_000, now = Date.now() } = {},
82
+ ) {
83
+ const result = { skipped: false, reason: null, removed: [], kept: [], scanned: 0, error: null };
84
+ if (platform === 'win32') {
85
+ result.skipped = true;
86
+ result.reason = 'not-applicable: on Windows these watcher scripts are really used, so none is removed';
87
+ return result;
88
+ }
89
+ if (typeof dir !== 'string' || dir === '') {
90
+ result.skipped = true;
91
+ result.reason = 'no state directory to sweep';
92
+ return result;
93
+ }
94
+ let names = [];
95
+ try {
96
+ names = readdirSync(dir);
97
+ } catch (error) {
98
+ // A directory that does not exist yet is not a failure: nothing was left.
99
+ const code = error && error.code;
100
+ if (code === 'ENOENT') {
101
+ result.skipped = true;
102
+ result.reason = 'no state directory yet';
103
+ return result;
104
+ }
105
+ result.error = error && error.message ? String(error.message) : String(error);
106
+ return result;
107
+ }
108
+ for (const name of names) {
109
+ if (!LEGACY_WATCH_SCRIPT_PATTERN.test(name)) continue;
110
+ result.scanned += 1;
111
+ const file = path.join(dir, name);
112
+ try {
113
+ const stat = statSync(file);
114
+ if (!stat.isFile()) {
115
+ result.kept.push(name);
116
+ continue;
117
+ }
118
+ // Never touch a file that was just written: the writer may still be running.
119
+ if (minAgeMs > 0 && now - stat.mtimeMs < minAgeMs) {
120
+ result.kept.push(name);
121
+ continue;
122
+ }
123
+ if (!looksLikeWatchScript(readFileSync(file, 'utf8'))) {
124
+ result.kept.push(name);
125
+ continue;
126
+ }
127
+ unlinkSync(file);
128
+ result.removed.push(name);
129
+ } catch (error) {
130
+ result.kept.push(name);
131
+ result.error = error && error.message ? String(error.message) : String(error);
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
21
137
  /** Default name of the one-shot scheduled-task fallback. */
22
138
  export const DEFAULT_TASK_NAME = 'dsh-autorestart-restart';
23
139
 
@@ -497,6 +613,7 @@ export function relaunchCommandLine(exe) {
497
613
  * appExePath?: string|null,
498
614
  * relaunchDelaySeconds?: number,
499
615
  * taskkillImageName?: string,
616
+ * cookieClearCommand?: string,
500
617
  * }} [options]
501
618
  * @returns {string}
502
619
  */
@@ -504,6 +621,7 @@ export function buildRestartCommand({
504
621
  appExePath = null,
505
622
  relaunchDelaySeconds = DEFAULT_RELAUNCH_DELAY_SECONDS,
506
623
  taskkillImageName = DEFAULT_IMAGE_NAME,
624
+ cookieClearCommand = '',
507
625
  } = {}) {
508
626
  const seconds =
509
627
  typeof relaunchDelaySeconds === 'number' && Number.isFinite(relaunchDelaySeconds) && relaunchDelaySeconds >= 0
@@ -511,9 +629,18 @@ export function buildRestartCommand({
511
629
  : DEFAULT_RELAUNCH_DELAY_SECONDS;
512
630
  const image = nonEmptyString(taskkillImageName) ?? DEFAULT_IMAGE_NAME;
513
631
  const exe = typeof appExePath === 'string' ? appExePath : '';
632
+ const clear = nonEmptyString(cookieClearCommand);
514
633
  // `ping -n N` where N = seconds + 1: one packet is sent immediately, so a
515
634
  // 4-second delay needs 5 pings. `>nul` keeps the detached console silent.
516
- return `cmd.exe /c ping -n ${seconds + 1} 127.0.0.1 >nul & taskkill /IM "${image}" /T /F & ${relaunchCommandLine(exe)}`;
635
+ const steps = [
636
+ `cmd.exe /c ping -n ${seconds + 1} 127.0.0.1 >nul`,
637
+ `taskkill /IM "${image}" /T /F`,
638
+ ];
639
+ // The cleanup runs AFTER the kill and BEFORE the relaunch: only then has
640
+ // Chromium left, and only then is the cookie jar unlocked.
641
+ if (clear !== null) steps.push(clear);
642
+ steps.push(relaunchCommandLine(exe));
643
+ return steps.join(' & ');
517
644
  }
518
645
 
519
646
  /**
@@ -550,6 +677,7 @@ export function buildWmiRestartCommand(command) {
550
677
  * relaunchDelaySeconds?: number,
551
678
  * gateUrl?: string,
552
679
  * taskkillImageName?: string,
680
+ * cookieClearCommand?: string,
553
681
  * }} [options]
554
682
  * @returns {string} `.cmd` script text (CRLF line endings, no BOM)
555
683
  */
@@ -559,6 +687,7 @@ export function buildGateScript({
559
687
  gateUrl = '',
560
688
  taskkillImageName = DEFAULT_IMAGE_NAME,
561
689
  watchScriptPath = null,
690
+ cookieClearCommand = '',
562
691
  } = {}) {
563
692
  const seconds =
564
693
  typeof relaunchDelaySeconds === 'number' && Number.isFinite(relaunchDelaySeconds) && relaunchDelaySeconds >= 0
@@ -573,6 +702,7 @@ export function buildGateScript({
573
702
  + gateArgument
574
703
  + ' -TimeoutSec 5; if ($r.allowRestart -ne $true) { exit 3 } } catch { exit 4 }"';
575
704
  const watch = nonEmptyString(watchScriptPath);
705
+ const clear = nonEmptyString(cookieClearCommand);
576
706
  const lines = [
577
707
  '@echo off',
578
708
  'rem dsh-autorestart: wait, re-check the idle gate, then kill + relaunch DeepSeek Harness.',
@@ -581,8 +711,11 @@ export function buildGateScript({
581
711
  'if errorlevel 1 exit /b 0',
582
712
  'taskkill /IM "' + image + '" /T /F',
583
713
  'ping -n 3 127.0.0.1 >nul',
584
- relaunchCommandLine(exe),
585
714
  ];
715
+ // The cleanup runs AFTER the kill and BEFORE the relaunch: only then has
716
+ // Chromium left, and only then is the cookie jar unlocked.
717
+ if (clear !== null) lines.push(clear);
718
+ lines.push(relaunchCommandLine(exe));
586
719
  if (watch !== null) {
587
720
  lines.push('rem dsh-autorestart: wait for the host to come back; roll the backup back when it does not.');
588
721
  // Launched as its own hidden process so the .cmd can end immediately: the
@@ -820,6 +953,77 @@ export function writeTextFileAtomic(file, text) {
820
953
  }
821
954
  }
822
955
 
956
+ /**
957
+ * Is this pid still alive?
958
+ *
959
+ * Best effort, never throws: `process.kill(pid, 0)` sends no signal, it only
960
+ * asks the operating system whether the process exists. `EPERM` means "it
961
+ * exists, but it is not mine" — still alive.
962
+ *
963
+ * @param {number|null} pid
964
+ * @param {{ kill?: Function }} [options]
965
+ * @returns {boolean}
966
+ */
967
+ export function isProcessAlive(pid, { kill = process.kill } = {}) {
968
+ const value = Number(pid);
969
+ if (!Number.isFinite(value) || value <= 0) return false;
970
+ try {
971
+ kill(value, 0);
972
+ return true;
973
+ } catch (error) {
974
+ return Boolean(error && error.code === 'EPERM');
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Did the rollback watcher really survive its own start?
980
+ *
981
+ * The POSIX launch only proves that `spawn` returned a pid: the detached child
982
+ * may still die a moment later (a missing `node`, a script that cannot be read).
983
+ * Waiting a moment and asking again is the difference between "armed" and
984
+ * "probably armed" — and the caller is told which one it got.
985
+ *
986
+ * @param {{
987
+ * pid?: number|null,
988
+ * waitMs?: number,
989
+ * sleep?: (ms: number) => Promise<void>,
990
+ * isAlive?: (pid: number) => boolean,
991
+ * }} [options]
992
+ * @returns {Promise<{ confirmed: boolean, alive: boolean, pid: number|null, reason: string }>}
993
+ */
994
+ export async function confirmWatcherAlive({
995
+ pid = null,
996
+ waitMs = 250,
997
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
998
+ isAlive = isProcessAlive,
999
+ } = {}) {
1000
+ const value = Number(pid);
1001
+ if (!Number.isFinite(value) || value <= 0) {
1002
+ return { confirmed: false, alive: false, pid: null, reason: 'no pid was reported, so nothing could be confirmed' };
1003
+ }
1004
+ const wait = Number.isFinite(Number(waitMs)) && Number(waitMs) > 0 ? Math.trunc(Number(waitMs)) : 0;
1005
+ if (wait > 0) {
1006
+ try {
1007
+ await sleep(wait);
1008
+ } catch { /* a clock that fails must not turn into "confirmed" */ }
1009
+ }
1010
+ const alive = (() => {
1011
+ try {
1012
+ return isAlive(value) === true;
1013
+ } catch {
1014
+ return false;
1015
+ }
1016
+ })();
1017
+ return {
1018
+ confirmed: alive,
1019
+ alive,
1020
+ pid: value,
1021
+ reason: alive
1022
+ ? `the watcher process ${value} is alive`
1023
+ : `the watcher process ${value} is gone right after its start`,
1024
+ };
1025
+ }
1026
+
823
1027
  /**
824
1028
  * Start the rollback watcher as a process that survives the death of the host.
825
1029
  *
@@ -833,6 +1037,11 @@ export function writeTextFileAtomic(file, text) {
833
1037
  * its own session and therefore immune to a kill of the host's process
834
1038
  * group.
835
1039
  *
1040
+ * WHAT `ok: true` MEANS (2026-11-02): the launch mechanism accepted the command.
1041
+ * It does NOT mean "somebody is definitely waiting" — on POSIX the child may
1042
+ * still die a moment later, so the caller must confirm the pid
1043
+ * ({@link confirmWatcherAlive}) before it reports "the rollback is armed".
1044
+ *
836
1045
  * @param {{
837
1046
  * watchScriptPath?: string|null,
838
1047
  * watchCommand?: string|null,
@@ -849,6 +1058,8 @@ export function writeTextFileAtomic(file, text) {
849
1058
  * command: string,
850
1059
  * error: string|null,
851
1060
  * dryRun: boolean,
1061
+ * pid: number|null,
1062
+ * spawnError: string|null,
852
1063
  * }}
853
1064
  */
854
1065
  export function launchDetachedWatcher({
@@ -869,6 +1080,10 @@ export function launchDetachedWatcher({
869
1080
  command,
870
1081
  error: null,
871
1082
  dryRun: dryRun === true,
1083
+ // The pid is what the caller needs to confirm that somebody is really
1084
+ // waiting: `ok: true` alone only says "the launcher accepted it".
1085
+ pid: /** @type {number|null} */ (null),
1086
+ spawnError: /** @type {string|null} */ (null),
872
1087
  };
873
1088
  if (command === '') {
874
1089
  return { ...base, error: 'launchDetachedWatcher requires a watcher command' };
@@ -926,18 +1141,31 @@ export function launchDetachedWatcher({
926
1141
 
927
1142
  // POSIX: a detached child is its own session leader (setsid), so it survives
928
1143
  // the death of the host — even when the host is killed as a process group.
1144
+ //
1145
+ // THE SPAWN ERROR IS NO LONGER SWALLOWED (2026-11-02): the old code attached
1146
+ // an empty `error` handler and returned `ok: true` as soon as a pid existed.
1147
+ // With `shell: true` a pid exists even when the real command cannot be
1148
+ // executed at all (a missing `node`, an unreadable script), so "the rollback
1149
+ // is armed" was reported for a watcher that never ran. The launch now returns
1150
+ // the pid and the caller CONFIRMS it (`confirmWatcherAlive`); the error
1151
+ // listener keeps the reason so a late failure is at least recorded.
929
1152
  try {
930
1153
  const child = spawnFn(command, { shell: true, detached: true, stdio: 'ignore' });
931
- if (typeof child.on === 'function') child.on('error', () => {});
1154
+ const pid = child && typeof child.pid === 'number' ? child.pid : null;
1155
+ if (typeof child.on === 'function') {
1156
+ child.on('error', (error) => {
1157
+ base.spawnError = errorText(error);
1158
+ });
1159
+ }
932
1160
  try {
933
1161
  child.unref();
934
1162
  } catch {
935
1163
  /* nothing else to do */
936
1164
  }
937
- if (typeof child.pid !== 'number') {
1165
+ if (pid === null) {
938
1166
  return { ...base, method: 'spawn', error: 'the watcher could not be started' };
939
1167
  }
940
- return { ...base, ok: true, method: 'spawn' };
1168
+ return { ...base, ok: true, method: 'spawn', pid };
941
1169
  } catch (error) {
942
1170
  return { ...base, method: 'spawn', error: errorText(error) };
943
1171
  }
@@ -959,6 +1187,7 @@ export function launchDetachedWatcher({
959
1187
  * dryRun?: boolean,
960
1188
  * gateUrl?: string|null,
961
1189
  * scriptPath?: string|null,
1190
+ * cookieClearCommand?: string,
962
1191
  * }} [options]
963
1192
  * @returns {{
964
1193
  * ok: boolean,
@@ -986,12 +1215,14 @@ export function scheduleDetachedRestart({
986
1215
  scriptPath = null,
987
1216
  watchScriptPath = null,
988
1217
  watchScript = null,
1218
+ cookieClearCommand = '',
989
1219
  } = {}) {
990
1220
  const task = nonEmptyString(taskName) ?? DEFAULT_TASK_NAME;
991
1221
  const gate = nonEmptyString(gateUrl);
992
1222
  const scriptFile = nonEmptyString(scriptPath);
993
1223
  const watchFile = nonEmptyString(watchScriptPath);
994
1224
  const watchText = typeof watchScript === 'string' ? watchScript : null;
1225
+ const clear = nonEmptyString(cookieClearCommand);
995
1226
  const gated = gate !== null;
996
1227
  let script = null;
997
1228
  let command;
@@ -1002,10 +1233,16 @@ export function scheduleDetachedRestart({
1002
1233
  gateUrl: gate,
1003
1234
  taskkillImageName,
1004
1235
  watchScriptPath: watchFile,
1236
+ cookieClearCommand: clear,
1005
1237
  });
1006
1238
  command = scriptFile === null ? '' : 'cmd.exe /c "' + scriptFile + '"';
1007
1239
  } else {
1008
- command = buildRestartCommand({ appExePath, relaunchDelaySeconds, taskkillImageName });
1240
+ command = buildRestartCommand({
1241
+ appExePath,
1242
+ relaunchDelaySeconds,
1243
+ taskkillImageName,
1244
+ cookieClearCommand: clear,
1245
+ });
1009
1246
  }
1010
1247
  const wmiCommand = buildWmiRestartCommand(command);
1011
1248
  const schtasksCreate = ['/Create', '/SC', 'ONCE', '/ST', '23:59', '/TN', task, '/TR', command, '/F'];
@@ -41,6 +41,16 @@ export const SCHEDULER_OK = 'ok';
41
41
  export const SCHEDULER_BLOCKED = 'blocked';
42
42
  /** Nobody checked yet. */
43
43
  export const SCHEDULER_UNKNOWN = 'unknown';
44
+ /**
45
+ * THE QUESTION DOES NOT APPLY HERE (2026-11-02).
46
+ *
47
+ * Only Windows has the one-off scheduled task this probe is about. The answer
48
+ * used to be `ok` — a green tick for a check that never ran, which made the
49
+ * page and the report claim something nobody had measured. `not-applicable` is
50
+ * a DIFFERENT answer from `ok`: it never counts as "passed", and the caller
51
+ * must say "this check does not apply on this machine" instead.
52
+ */
53
+ export const SCHEDULER_NOT_APPLICABLE = 'not-applicable';
44
54
 
45
55
  /** The name of the probe task. It is unique per run, so two probes cannot collide. */
46
56
  export const PROBE_TASK_PREFIX = 'dsh-autorestart-capability-probe';
@@ -173,11 +183,14 @@ export function probeSchedulerCapability({
173
183
  const evidence = { platform, taskName, steps: [] };
174
184
 
175
185
  // Only Windows uses a one-off scheduled task. Everywhere else the question
176
- // does not apply (the advice for those platforms is elsewhere).
186
+ // does not apply — and the answer SAYS SO (`not-applicable`), because `ok`
187
+ // was a green tick for a check that never ran (2026-11-02).
177
188
  if (platform !== 'win32') {
178
189
  return {
179
- scheduler: SCHEDULER_OK,
190
+ scheduler: SCHEDULER_NOT_APPLICABLE,
180
191
  checkedAt,
192
+ applicable: false,
193
+ reason: 'not applicable: this platform does not use a one-off scheduled task',
181
194
  evidence: {
182
195
  ...evidence,
183
196
  applicable: false,
@@ -296,5 +309,8 @@ export function emptyCapabilities() {
296
309
  export function isCapabilities(raw) {
297
310
  if (!raw || typeof raw !== 'object') return false;
298
311
  const scheduler = raw.scheduler;
299
- return scheduler === SCHEDULER_OK || scheduler === SCHEDULER_BLOCKED || scheduler === SCHEDULER_UNKNOWN;
312
+ return scheduler === SCHEDULER_OK
313
+ || scheduler === SCHEDULER_BLOCKED
314
+ || scheduler === SCHEDULER_UNKNOWN
315
+ || scheduler === SCHEDULER_NOT_APPLICABLE;
300
316
  }