@xiaoyuyu6420/dsh-backup 0.13.2 → 0.13.4

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/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fs
47
47
  import { createRequire } from 'node:module';
48
48
  import { hostname } from 'node:os';
49
49
  import { basename, dirname, join } from 'node:path';
50
+ import { StringDecoder } from 'node:string_decoder';
50
51
  import { fileURLToPath } from 'node:url';
51
52
  import * as zlib from 'node:zlib';
52
53
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -518,7 +519,7 @@ class BackupPanelService extends TypertRemoteService {
518
519
  return this.ops.doctorRepair(selector, signal);
519
520
  }
520
521
 
521
- /** 迁移预检:只读扫描全部会话日志(含 v1/v2/v3 各代),预测升级后哪些会话打不开。 */
522
+ /** 迁移预检:只读扫描全部会话日志(v0 起各代,按文件名代判定),预测升级后哪些会话打不开。 */
522
523
  migrateCheck(signal) {
523
524
  return this.ops.migrateCheck(signal);
524
525
  }
@@ -610,10 +611,16 @@ export function apply(ctx, pluginConfig) {
610
611
 
611
612
  // ---------- 工具函数 ----------
612
613
  /**
613
- * 读取 subprocess 收集的输出流。dsh-subprocess 的收集契约:输出超过
614
- * maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
615
- * 完整输出 spill 到 spillPath——restore 的 `tar -tvzf` 校验清单必须看
616
- * 全量,否则大归档的校验可被清单前段的恶意条目绕过(8KB 尾窗看不到)。
614
+ * 读取 subprocess 收集的输出流(诊断/小输出用)。dsh-subprocess 的收集契约:
615
+ * 输出超过 maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
616
+ * 完整输出 spill 到 spillPath。
617
+ *
618
+ * ⚠️ **不要用它读体积不可控、且结果参与安全判定的输出**(#119):spill 自身还有
619
+ * 上限,宿主对超过上限的流会**丢弃 spill 文件**(`discardSpill()`),此时
620
+ * `readFrom` 只给内存尾窗、`lossy: true` 且**没有 spillPath**——旧实现把这段
621
+ * 截断文本原样返回,`tar -tvzf` 校验清单因此把"断在文件名中间的碎片"当成不安全
622
+ * 条目,整包被拒绝恢复(备份成功、verify 报完整、面板正常列出,只在恢复时炸)。
623
+ * 这类输出请走 {@link streamListingLines}(原始流逐行消费,无上限)。
617
624
  */
618
625
  async function collectOutput(col) {
619
626
  const read = col?.readFrom(0);
@@ -628,6 +635,65 @@ export function apply(ctx, pluginConfig) {
628
635
  return read.text ?? '';
629
636
  }
630
637
 
638
+ /**
639
+ * 逐行消费一条命令的 stdout(`stdio: 'pipe'` 原始流形态),把每一行交给
640
+ * `onLine`;`onLine` 返回 false 时立即终止子进程并提前返回("发现违规就不必读完")。
641
+ *
642
+ * 为什么不用有界收集(#119):见 {@link collectOutput} 的 ⚠️。这里内存只保留
643
+ * "尚未收尾的一行",清单多大都不截断,也不会把碎片当成一行;stderr 仍走有界
644
+ * 收集(只作诊断,几 KB 足够)。
645
+ */
646
+ async function streamListingLines(argv, cwd, signal, onLine) {
647
+ const proc = ctx.subprocess.spawn({
648
+ argv,
649
+ cwd,
650
+ graceMs: 5000,
651
+ signal,
652
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } } },
653
+ });
654
+ const stream = proc.stdout;
655
+ if (!stream) throw new Error('宿主未提供原始输出流(subprocess stdout:"pipe" 不可用)——请升级宿主后重试');
656
+ // 单行长度的护栏:tar -tv 的一行正常不过几百字节;给足余量后仍设上限,
657
+ // 避免被构造出的超长名字把 carry 撑成内存炸弹。
658
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
659
+ const decoder = new StringDecoder('utf8');
660
+ let bytes = 0;
661
+ let lines = 0;
662
+ let carry = '';
663
+ let stopped = false;
664
+ for await (const chunk of stream) {
665
+ bytes += chunk.length;
666
+ carry += decoder.write(chunk);
667
+ let idx;
668
+ while ((idx = carry.indexOf('\n')) >= 0) {
669
+ const line = carry.slice(0, idx);
670
+ carry = carry.slice(idx + 1);
671
+ lines += 1;
672
+ if (onLine(line) === false) { stopped = true; break; }
673
+ }
674
+ if (stopped) break;
675
+ if (carry.length > MAX_LINE_BYTES) throw new Error(`输出单行超过 ${MAX_LINE_BYTES} 字节上限——拒绝继续解析`);
676
+ }
677
+ if (stopped) {
678
+ proc.terminate();
679
+ } else {
680
+ carry += decoder.end();
681
+ if (carry.length) { lines += 1; onLine(carry); }
682
+ }
683
+ let err = '';
684
+ try {
685
+ err = await collectOutput(proc.collected?.stderr);
686
+ } catch { /* 诊断文本尽力而为 */ }
687
+ const outcome = await proc.done;
688
+ // 取消优先于一切结果分类(与 spawnRun 同款取舍)
689
+ if (signal?.aborted) throw new Error('操作已取消');
690
+ if (!stopped) {
691
+ if (outcome.signal != null) throw new Error(`命令被终止(signal=${outcome.signal})`);
692
+ if (outcome.exitCode !== 0) throw new Error(`命令失败 exit=${outcome.exitCode}: ${err}`);
693
+ }
694
+ return { bytes, lines, stopped };
695
+ }
696
+
631
697
  async function spawnRun(argv, cwd, signal) {
632
698
  const proc = ctx.subprocess.spawn({
633
699
  argv,
@@ -762,17 +828,33 @@ export function apply(ctx, pluginConfig) {
762
828
  }
763
829
  }
764
830
 
831
+ /** 读回 profile 内实际解析到的本插件版本(pnpm 把直接依赖链接在 profile 的
832
+ * node_modules 下);路径异常或读不到时返回 null,调用方按「无法核对」处理。 */
833
+ async function installedVersion(profile) {
834
+ try {
835
+ const meta = JSON.parse(await readFile(join(paths().dshHome, 'profiles', profile, 'node_modules', SELF_META.name, 'package.json'), 'utf8'));
836
+ return typeof meta?.version === 'string' && meta.version ? meta.version : null;
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
841
+
765
842
  /**
766
843
  * 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
767
- * --profile <p> update <pkg>`(pnpm 语义——profile 是 pnpm 项目,插件绝不
768
- * 自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主在启动
769
- * 时结算 Loader 树,Node 半不可在运行中热替换。
844
+ * --profile <p> update --latest <pkg>`(pnpm 语义——profile 是 pnpm 项目,
845
+ * 插件绝不自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主
846
+ * 在启动时结算 Loader 树,Node 半不可在运行中热替换。
847
+ *
848
+ * `--latest` 不可省:pnpm update <pkg> 只在 profile 声明的版本范围内升级
849
+ * (安装时写入的是 ^x.y.z),跨 minor 会停在旧 minor 上——而升级按钮承诺的
850
+ * 目标通常正是新 minor,少了它就是「点了更新、版本没动」。命令跑完再读回
851
+ * profile 内的真实版本核对,不拿 registry 的 latest 冒充结果。
770
852
  */
771
853
  async function runUpdate(signal, profileArg) {
772
854
  const profile = profileArg || 'web';
773
855
  const chk = await checkUpdate(signal);
774
856
  if (chk.error) {
775
- return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}` };
857
+ return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}` };
776
858
  }
777
859
  if (!chk.update) {
778
860
  return { ok: true, updated: false, current: chk.current, latest: chk.latest, summary: `已是最新版本 ${chk.current}` };
@@ -788,12 +870,12 @@ export function apply(ctx, pluginConfig) {
788
870
  // detectHostTrain 同款定位——`node <入口> plugin ...` 与运行中的宿主同源)
789
871
  let argv = null;
790
872
  try {
791
- argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', SELF_META.name];
873
+ argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
792
874
  } catch {
793
875
  try {
794
- argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', SELF_META.name];
876
+ argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
795
877
  } catch {
796
- return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
878
+ return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
797
879
  }
798
880
  }
799
881
  try {
@@ -802,12 +884,26 @@ export function apply(ctx, pluginConfig) {
802
884
  } catch (err) {
803
885
  return { ok: false, updated: false, summary: `更新失败:${friendlyReason(err)}${snap ? `\n已有升级前快照 ${snap},可 /backup restore ${snap} 回滚。` : ''}` };
804
886
  }
887
+ // 命令跑完 ≠ 版本已变:profile 里锁着精确版本、pnpm 配置忽略 --latest 等情况
888
+ // 都会让结果停在旧版,读回真实版本再回执(否则用户重启后仍是旧版,无从解释)。
889
+ const after = await installedVersion(profile);
890
+ if (after && after !== chk.latest) {
891
+ return {
892
+ ok: false,
893
+ updated: false,
894
+ current: after,
895
+ latest: chk.latest,
896
+ summary: `更新命令已执行,但 profile 内解析到的仍是 ${after}(目标 ${chk.latest})。`
897
+ + `\n请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}`
898
+ + (snap ? `\n更新前已留升级前快照 ${snap}。` : ''),
899
+ };
900
+ }
805
901
  return {
806
902
  ok: true,
807
903
  updated: true,
808
- current: chk.latest,
904
+ current: after || chk.latest,
809
905
  latest: chk.latest,
810
- summary: `已更新到 ${chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
906
+ summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
811
907
  };
812
908
  }
813
909
 
@@ -1711,7 +1807,29 @@ export function apply(ctx, pluginConfig) {
1711
1807
  }
1712
1808
  }
1713
1809
  if (nameIdx < 0 || nameIdx >= f.length) return null;
1714
- return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
1810
+ const rest = f.slice(nameIdx).join(' ');
1811
+ // tar -tv 的链接形态:硬链接 `<名> link to <目标>`、符号链接 `<名> -> <目标>`。
1812
+ // 必须把目标摘出来单独校验:旧实现把它并进 name,于是硬链接条目的"名字"
1813
+ // 天然含空格与目标路径,必然被判非法而整体拒绝(#112),错误信息里也把
1814
+ // 目标一起印出来。
1815
+ let name = rest;
1816
+ let link = null;
1817
+ const hardAt = rest.indexOf(' link to ');
1818
+ if (hardAt >= 0) {
1819
+ name = rest.slice(0, hardAt);
1820
+ link = rest.slice(hardAt + ' link to '.length);
1821
+ } else {
1822
+ const symAt = rest.lastIndexOf(' -> ');
1823
+ if (symAt >= 0) {
1824
+ name = rest.slice(0, symAt);
1825
+ link = rest.slice(symAt + 4);
1826
+ }
1827
+ }
1828
+ return {
1829
+ type: f[0][0],
1830
+ name: name.replace(/\/$/, ''),
1831
+ link: link === null ? null : link.replace(/\/$/, ''),
1832
+ };
1715
1833
  }
1716
1834
 
1717
1835
  /**
@@ -1772,33 +1890,40 @@ export function apply(ctx, pluginConfig) {
1772
1890
  const { root, dshHome } = paths();
1773
1891
  const base = dshHome.split('/').pop();
1774
1892
  const tar = await ctx.subprocess.resolveExecutable('tar');
1775
- const listed = await spawnRun([tar, '-tvzf', pickedName], root, signal);
1776
- // tar 路径穿越防护:逐行解析 -tvzf 输出(双布局,见 parseTarEntry),
1777
- // 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
1778
- // 即整体拒绝,恢复绝不触碰备份根之外的文件。
1893
+ // 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
1894
+ // 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
1779
1895
  const entries = [];
1780
1896
  const bad = [];
1781
- for (const line of listed.out.split('\n')) {
1897
+ // 路径检查(反斜杠归一化后再判):`.dsh/..\..\escape.txt` 与 `/` 分隔的
1898
+ // `..` 段同样拒绝(Windows 风格路径变体)。
1899
+ const insideBase = (p) => Boolean(p)
1900
+ && !p.startsWith('/') && !/^[A-Za-z]:/.test(p)
1901
+ && !p.split('/').includes('..')
1902
+ && (p === base || p.startsWith(`${base}/`));
1903
+ const onLine = (line) => {
1782
1904
  const s = line.trim();
1783
- if (!s) continue;
1905
+ if (!s) return true;
1784
1906
  const parsed = parseTarEntry(s);
1785
- // 反斜杠归一化后再做全部路径检查:`.dsh/..\..\escape.txt` 与
1786
- // `/` 分隔的 `..` 段同样拒绝(Windows 风格路径变体)
1787
1907
  const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
1788
- if (
1789
- !parsed || !name
1790
- || (parsed.type !== '-' && parsed.type !== 'd')
1791
- || name.startsWith('/') || /^[A-Za-z]:/.test(name)
1792
- || name.split('/').includes('..')
1793
- || (name !== base && !name.startsWith(`${base}/`))
1794
- ) {
1908
+ const link = parsed && parsed.link ? parsed.link.replace(/\\/g, '/').replace(/\/$/, '') : null;
1909
+ // 允许的类型:普通文件 '-'、目录 'd',以及**目标也在备份根内**的硬链接 'h'。
1910
+ // 硬链接是 DSH 附件库(attachments/v1:files/ 与 file-objects/ 同一 inode)的
1911
+ // 正常形态,tar 会把第二个路径写成 'h' 条目——旧实现只放行 -/d,于是**带附件的
1912
+ // 归档一律被判"不安全条目"整体拒绝**,完整恢复/dry-run/分类型合并/doctor 修复/
1913
+ // 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
1914
+ // 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
1915
+ const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
1916
+ const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
1917
+ if (!plainOk && !hardOk) {
1795
1918
  bad.push(parsed ? name : s);
1796
1919
  } else {
1797
1920
  entries.push(name);
1798
1921
  }
1799
- }
1922
+ return bad.length === 0; // 发现违规立即停读并终止 tar,不必读完
1923
+ };
1924
+ const listed = await streamListingLines([tar, '-tvzf', pickedName], root, signal, onLine);
1800
1925
  if (bad.length) {
1801
- throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
1926
+ throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}(已读清单 ${listed.lines} 行 / ${listed.bytes} 字节),恢复已中止`);
1802
1927
  }
1803
1928
  return entries;
1804
1929
  }
@@ -2128,7 +2253,17 @@ export function apply(ctx, pluginConfig) {
2128
2253
  // (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
2129
2254
  // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
2130
2255
  const ZSTD_MAGIC = 0xfd2fb528;
2131
- const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
2256
+ // 会话日志文件名 = 宿主 sessionFormatLogFilename 的形态:session[.v<N>].jsonl[.zstd]
2257
+ // (N≥1 可多位;无 .vN 后缀即第 0 代)。**代数是文件名的一部分、会随格式换代前进**,
2258
+ // 写死具体名字会在换代当天整体失明(#117:v4 机器上 doctor/migrate 一份都看不见),
2259
+ // 故按形态匹配,让新代文件至少进入校验/预检流程(代的判定另见迁移预检)。
2260
+ const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
2261
+ const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
2262
+ /** 从会话日志文件名取格式代;非会话日志名返回 null(无 .vN 后缀即 v0)。 */
2263
+ function sessionLogVersion(name) {
2264
+ const m = SESSION_LOG_RE.exec(name);
2265
+ return m ? Number(m[1] ?? 0) : null;
2266
+ }
2132
2267
  const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
2133
2268
  const SCAN_MAX_FILES = 4000;
2134
2269
  const SCAN_MAX_DEPTH = 8;
@@ -2273,7 +2408,7 @@ export function apply(ctx, pluginConfig) {
2273
2408
  }
2274
2409
 
2275
2410
  /** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
2276
- async function collectSessionLogs(dir, relPrefix, depth, out, names = SESSION_LOG_NAMES) {
2411
+ async function collectSessionLogs(dir, relPrefix, depth, out, isLogName = isSessionLogName) {
2277
2412
  if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
2278
2413
  let dirents;
2279
2414
  try {
@@ -2286,8 +2421,8 @@ export function apply(ctx, pluginConfig) {
2286
2421
  const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
2287
2422
  if (d.isDirectory()) {
2288
2423
  if (SCAN_SKIP_DIRS.has(d.name)) continue;
2289
- await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, names);
2290
- } else if (names.has(d.name)) {
2424
+ await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, isLogName);
2425
+ } else if (isLogName(d.name)) {
2291
2426
  out.push({ abs: `${dir}/${d.name}`, rel });
2292
2427
  }
2293
2428
  }
@@ -2371,16 +2506,13 @@ export function apply(ctx, pluginConfig) {
2371
2506
  // (0.1.5-rc.2 校准,与 0.11.2 对齐 isHeaderLine 同一套路),覆盖"解析即判"
2372
2507
  // 的规则;需要重放迁移过程的规则(chunk 折叠溯源、引用重映射、worker 复核)
2373
2508
  // 不在覆盖范围,结果里如实标注。三层判定:fail=会话将打不开;migratable=
2374
- // 旧代但静态规则全过(首次写开会发布 v3 新代);ok=已是当前代。
2375
- const MIGRATE_CURRENT_VERSION = 3;
2376
- /** 物理代文件名 ↔ 格式代(宿主 sessionFormatLogFilename;文件名与 header 不一致宿主拒载)。 */
2377
- const MIGRATE_LOG_NAMES = [
2378
- { name: 'session.jsonl', version: 0 },
2379
- { name: 'session.v1.jsonl', version: 1 },
2380
- { name: 'session.v2.jsonl', version: 2 },
2381
- { name: 'session.v3.jsonl', version: 3 },
2382
- ];
2383
- const MIGRATE_LOG_SET = new Set(MIGRATE_LOG_NAMES.flatMap((g) => [g.name, `${g.name}.zstd`]));
2509
+ // 旧代但静态规则全过(首次写开会发布当前代新文件);ok=已是当前代。
2510
+ //
2511
+ // **校准代(#117)**:宿主会随格式换代推进文件名与 header 的 version(0.1.7 起
2512
+ // 为 v4——迁移包只到 `v3-to-v4`、无 `v4-to-v5`)。此常数是本插件**规则**覆盖到的
2513
+ // 最高代,换代时须同步更新;比它更新的日志只提示、不判"打不开"(见
2514
+ // newer-generation)——"宿主读不读得了"取决于宿主版本,不是我们这张注定滞后的表。
2515
+ const MIGRATE_CALIBRATED_VERSION = 4;
2384
2516
  // 冻结事件清单(RELEASED_V0_EVENT_TYPES / RELEASED_V2_EVENT_TYPES,宿主
2385
2517
  // 按契约不增长旧代清单);migration 对清单外类型连 ignorable 也拒(#6355)。
2386
2518
  const MIGRATE_V0_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
@@ -2388,14 +2520,26 @@ export function apply(ctx, pluginConfig) {
2388
2520
  /** v0 侧退役类型(pre-react-loop 预发布形态;宿主点名拒绝)。 */
2389
2521
  const MIGRATE_V0_RETIRED_TYPES = new Set(['request/header-delta', 'mode/set']);
2390
2522
  /** 消息来源 kind 白名单(v3 侧 15 类;未知 kind 在 v2→v3 边被拒——#6355 主症状)。 */
2391
- const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message']);
2523
+ /**
2524
+ * 「消息来源 kind」的**已知**集合(#113)。注意语义:宿主的 `source.kind` 是
2525
+ * **可合并扩展的联合类型**——每个生产者在自己的模块里声明自己的 kind,没有
2526
+ * 统一的兜底值,所以任何硬编码清单都**不可能完整**。这里的集合因此只当"已知
2527
+ * 名单"用:命中的静默通过,没命中的记一条**提示**(不再判为"打不开",见
2528
+ * 下方 unknown-source-kind)——真正会拒载的是旧代**事件类型**的冻结清单。
2529
+ *
2530
+ * 内容来自宿主 `@deepseek-ai/dsh-session-format-v3-to-v4` 自带的
2531
+ * `RENAMED_PRODUCERS` 取值与 `RELEASED_SAME_NAME_PRODUCERS` 名单
2532
+ * (2026-09-29 从 0.2.0-rc.1 实装包逐行核对),外加 `producerKind()` 的兜底
2533
+ * 形态 `plugin:<插件名>`(用前缀匹配,见 MIGRATE_KIND_KNOWN)。
2534
+ */
2535
+ const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'runtime-context', 'system-prompt', 'model-selection', 'plan-mode', 'time-context', 'tmux-context', 'user-approval', 'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal', 'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule', 'dsh-session-title-llm', 'fallback', 'provider']);
2392
2536
  const MIGRATE_MAX_FINDINGS = 3;
2537
+ /** kind 是否在已知名单内:精确命中,或宿主兜底形态 `plugin:<插件名>`(可任意长尾,故用前缀判定)。 */
2538
+ const MIGRATE_KIND_KNOWN = (kind) => MIGRATE_SOURCE_KINDS.has(kind) || kind.startsWith('plugin:');
2393
2539
 
2394
- /** 从物理文件名推格式代;非代文件名(未来扩展)返回 null。 */
2540
+ /** 非代文件名(未来扩展)返回 null;代的判定与 doctor 共用 SESSION_LOG_RE。 */
2395
2541
  function migrateFilenameVersion(fileName) {
2396
- const base = fileName.replace(/\.zstd$/, '');
2397
- const g = MIGRATE_LOG_NAMES.find((x) => x.name === base);
2398
- return g ? g.version : null;
2542
+ return sessionLogVersion(fileName);
2399
2543
  }
2400
2544
 
2401
2545
  /**
@@ -2413,18 +2557,26 @@ export function apply(ctx, pluginConfig) {
2413
2557
  if (!header || typeof header !== 'object' || typeof header.version !== 'number') return null;
2414
2558
  const sourceVersion = header.version;
2415
2559
  const findings = [];
2560
+ const warnings = [];
2416
2561
  let total = 0;
2417
2562
  const fail = (rule, detail) => {
2418
2563
  total += 1;
2419
2564
  if (findings.length < MIGRATE_MAX_FINDINGS) findings.push({ rule, detail });
2420
2565
  };
2421
- if (sourceVersion > MIGRATE_CURRENT_VERSION) {
2422
- fail('newer-than-host', `header version=${sourceVersion} 高于本宿主可读的 v${MIGRATE_CURRENT_VERSION}——日志由更新版本的宿主写出,读/写都会被拒(提示语会要求升级宿主)`);
2423
- return { sourceVersion, findings, total };
2566
+ // 提示(不判"打不开"):用于**开放集合**上的命中失败——最典型的是
2567
+ // source.kind(可合并扩展的联合类型,硬编码清单天生补不全,#113)。
2568
+ const warn = (rule, detail) => {
2569
+ if (warnings.length < MIGRATE_MAX_FINDINGS) warnings.push({ rule, detail });
2570
+ };
2571
+ if (sourceVersion > MIGRATE_CALIBRATED_VERSION) {
2572
+ // 更新的格式代:本插件的规则表对它是盲区,但"能不能读"由宿主决定(同代/更新可读、
2573
+ // 降级运行被拒)——按开放集合纪律只提示、不替宿主下"打不开"的判决(#117/#113)。
2574
+ warn('newer-generation', `header version=${sourceVersion} 高于本插件校准到的 v${MIGRATE_CALIBRATED_VERSION}——日志由更新的格式代写出。能否打开取决于宿主:宿主同代或更新即可读,**降级运行会被拒**;若你的宿主已是这一代,说明本插件校准滞后,欢迎反馈。仅提示,不判为打不开`);
2575
+ return { sourceVersion, findings, warnings, total };
2424
2576
  }
2425
2577
  if (fileVersion !== null && fileVersion !== sourceVersion) {
2426
2578
  fail('name-version-mismatch', `文件名标 v${fileVersion},header 标 v${sourceVersion}——宿主按不一致直接拒载`);
2427
- return { sourceVersion, findings, total };
2579
+ return { sourceVersion, findings, warnings, total };
2428
2580
  }
2429
2581
  // 事件行静态规则。seq 游标语义与 validateSessionLines 一致:v0 有 packed 行
2430
2582
  //(一条行多个 chunk),v1 起一行一事件。
@@ -2462,14 +2614,22 @@ export function apply(ctx, pluginConfig) {
2462
2614
  if (sourceVersion === 2 && (type === 'tool/code-dispatch' || type === 'tool/code-dispatch-start')) fail('ptc-reserved-v2', `第 ${i + 1} 行 ${type} 是 v3 保留 PTC 标签,v2→v3 拒绝(ignorable 也拒)`);
2463
2615
  if ((sourceVersion === 2 || sourceVersion === 3) && type === 'request/header' && rec.data?.header?.system !== undefined) fail('retired-header-system', `第 ${i + 1} 行 request/header 携带退役的 header.system——v2→v3 无法保留时序地提升它${sourceVersion === 3 ? ',当前代读取同样拒绝' : ''}`);
2464
2616
  const kind = rec?.data?.source?.kind;
2465
- if (typeof kind === 'string' && !MIGRATE_SOURCE_KINDS.has(kind)) fail('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在白名单——v2→v3 边拒绝(社区插件自定义 kind,#6355)`);
2617
+ // kind 只作提示,不作判据(#113):宿主把它设计成可合并扩展的联合类型,
2618
+ // 每个生产者(含任意社区插件)都能加自己的 kind,且生产端对未知 kind 照读
2619
+ // ——旧实现把"不在白名单"直接判成"打不开",在一台真实机器上把 81/82 份
2620
+ // 完全健康的日志报成不可打开。
2621
+ if (typeof kind === 'string' && !MIGRATE_KIND_KNOWN(kind)) {
2622
+ warn('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在已知名单(宿主为可合并扩展的联合类型,官方/社区插件都能新增)——仅提示,不判为打不开;真正会拒载的是旧代事件类型不在冻结清单内`);
2623
+ }
2466
2624
  }
2467
- return { sourceVersion, findings, total };
2625
+ return { sourceVersion, findings, warnings, total };
2468
2626
  }
2469
2627
 
2470
2628
  /**
2471
2629
  * 单文件迁移预检:解码(复用 doctor 的帧走查)→ 判代 → 静态规则。
2472
- * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, total, reason }。
2630
+ * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, warnings, total, reason }——
2631
+ * findings 是"判为打不开"的硬伤,warnings 是开放集合上的命中失败(如未知
2632
+ * source.kind),只提示不改变 verdict(#113)。
2473
2633
  */
2474
2634
  async function migrateAnalyzeFile(f) {
2475
2635
  const fileVersion = migrateFilenameVersion(f.abs.split('/').pop());
@@ -2484,8 +2644,8 @@ export function apply(ctx, pluginConfig) {
2484
2644
  if (!lines.length) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '解出的逻辑行为空' }], total: 1 };
2485
2645
  const r = migrateAnalyzeLines(lines, fileVersion);
2486
2646
  if (!r) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: 'header 行不可解析——按损坏处理(doctor 体检覆盖),迁移预检跳过' };
2487
- if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, total: r.total };
2488
- return { verdict: r.sourceVersion < MIGRATE_CURRENT_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], total: 0 };
2647
+ if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, warnings: r.warnings, total: r.total };
2648
+ return { verdict: r.sourceVersion < MIGRATE_CALIBRATED_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], warnings: r.warnings, total: 0 };
2489
2649
  } catch (err) {
2490
2650
  return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `解码失败(${String(err && err.message ? err.message : err)})——doctor 体检会按损坏处理` };
2491
2651
  }
@@ -2551,7 +2711,7 @@ export function apply(ctx, pluginConfig) {
2551
2711
  const { dshHome, root } = paths();
2552
2712
  if (signal?.aborted) throw new Error('操作已取消');
2553
2713
  const found = [];
2554
- await collectSessionLogs(dshHome, '', 0, found, MIGRATE_LOG_SET);
2714
+ await collectSessionLogs(dshHome, '', 0, found);
2555
2715
  const sessions = [];
2556
2716
  for (const f of found) {
2557
2717
  if (signal?.aborted) throw new Error('操作已取消');
@@ -2561,8 +2721,9 @@ export function apply(ctx, pluginConfig) {
2561
2721
  const fails = sessions.filter((s) => s.verdict === 'fail');
2562
2722
  const migratable = sessions.filter((s) => s.verdict === 'migratable');
2563
2723
  const skipped = sessions.filter((s) => s.verdict === 'skipped');
2724
+ const warned = sessions.filter((s) => (s.warnings?.length ?? 0) > 0);
2564
2725
  const lines = [];
2565
- lines.push(`扫描 ${sessions.length} 个会话日志(含 v1/v2/v3 代):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定`);
2726
+ lines.push(`扫描 ${sessions.length} 个会话日志(v0 起各代,按文件名代判定):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定${warned.length ? `、${warned.length} 个带提示(不影响打开)` : ''}`);
2566
2727
  if (fails.length) {
2567
2728
  lines.push('');
2568
2729
  for (const s of fails.slice(0, 15)) {
@@ -2574,24 +2735,33 @@ export function apply(ctx, pluginConfig) {
2574
2735
  lines.push('');
2575
2736
  lines.push('升级前建议:先拍一份升级前快照(/backup),坏的会话可从更早归档定点修复(/backup doctor --repair)。');
2576
2737
  }
2738
+ if (warned.length) {
2739
+ lines.push('');
2740
+ lines.push(`ℹ️ ${warned.length} 个日志带提示(**不计入"打不开"**,逐条看原因):`);
2741
+ for (const s of warned.slice(0, 5)) {
2742
+ for (const w of s.warnings) lines.push(` · ${s.rel}:${w.detail}`);
2743
+ }
2744
+ if (warned.length > 5) lines.push(` …另有 ${warned.length - 5} 个文件`);
2745
+ }
2577
2746
  if (!env.hardlink) {
2578
2747
  lines.push('');
2579
- lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布 v3 新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2748
+ lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2580
2749
  }
2581
2750
  if (env.credentialsFlat) {
2582
2751
  lines.push('');
2583
2752
  lines.push(`⚠️ .credentials.yaml 还是旧扁平布局——宿主下次读档会把它原子替换成 version:1(不可逆,旧宿主将读不回)。${env.credentialsPreserved ? `已自动存底:${env.credentialsPreserved.replace(`${root}/`, '')}` : '自动存底失败(vault 不可写?),建议立即手动 /backup 一次。'}`);
2584
2753
  }
2585
2754
  lines.push('');
2586
- lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准)。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2755
+ lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准;格式代校准到 v${MIGRATE_CALIBRATED_VERSION})。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2587
2756
  const head = fails.length || !env.hardlink || env.credentialsFlat ? '⚠️' : '✅';
2588
2757
  return {
2589
2758
  scanned: sessions.length,
2590
- sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.reason ? { reason: s.reason } : {}) })),
2759
+ sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}), ...(s.reason ? { reason: s.reason } : {}) })),
2591
2760
  env,
2592
2761
  failCount: fails.length,
2593
2762
  migratableCount: migratable.length,
2594
2763
  skippedCount: skipped.length,
2764
+ warningCount: warned.length,
2595
2765
  summary: `${head} ${lines.join('\n')}`,
2596
2766
  };
2597
2767
  }
@@ -3123,7 +3293,7 @@ export function apply(ctx, pluginConfig) {
3123
3293
  // ---------- backup_dsh 模型工具 ----------
3124
3294
  ctx.tools.register(defineTool({
3125
3295
  name: 'backup_dsh',
3126
- description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(含 v1/v2/v3 代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
3296
+ description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(v0 起各代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
3127
3297
  parameters: {
3128
3298
  mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor', 'migrate'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检,migrate=升级迁移预检' },
3129
3299
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
@@ -3188,8 +3358,11 @@ export function apply(ctx, pluginConfig) {
3188
3358
  failCount: r.failCount,
3189
3359
  migratableCount: r.migratableCount,
3190
3360
  skippedCount: r.skippedCount,
3361
+ warningCount: r.warningCount ?? 0,
3191
3362
  summary: r.summary,
3192
- sessions: r.sessions.filter((s) => s.verdict === 'fail').map((s) => ({ path: s.rel, version: s.version, findings: s.findings })),
3363
+ // fail 项照旧全带;带提示(不判打不开)的条目也带上 warnings——否则
3364
+ // UI 只能看到"没问题",用户无从知道有哪些开放集合上的命中失败(#113)。
3365
+ sessions: r.sessions.filter((s) => s.verdict === 'fail' || s.warnings?.length).map((s) => ({ path: s.rel, version: s.version, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}) })),
3193
3366
  env: r.env,
3194
3367
  };
3195
3368
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.13.2",
4
- "description": "一键备份与恢复 DeepSeek Harness(dsh)的全部用户数据(~/.dsh):定时自动备份、升级快照、会话日志体检修复、迁移预检、宿主起不来也能用的救援通道、凭据脱敏、GitHub 同步跨机迁移,附可视化面板。 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",
3
+ "version": "0.13.4",
4
+ "description": "一条命令备份、恢复与迁移 DeepSeek Harness(dsh)的全部数据(~/.dsh:会话、设置、插件、MCP、技能):定时自动备份、升级前快照、换机迁移、会话日志体检修复、宿主起不来也能用的进程外救援;凭据默认脱敏只存本机。 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data — sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
7
7
  "main": "lib/index.js",
@@ -43,12 +43,24 @@
43
43
  "备份",
44
44
  "restore",
45
45
  "恢复",
46
+ "migration",
47
+ "迁移",
48
+ "换机",
46
49
  "schedule",
47
50
  "自动备份",
48
51
  "cross-platform",
52
+ "cross-machine",
49
53
  "rescue",
50
54
  "doctor",
51
55
  "upgrade-snapshot",
56
+ "mcp",
57
+ "skills",
58
+ "技能",
59
+ "sessions",
60
+ "会话",
61
+ "workspace",
62
+ "工作区",
63
+ "vault",
52
64
  "数据安全"
53
65
  ],
54
66
  "peerDependencies": {