dsh-config-manager 0.2.3 → 0.3.0

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 (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
@@ -0,0 +1,205 @@
1
+ /**
2
+ * SEC-05 回归:校验表只做「正向」校验 —— 表里登记的逐条比对,**表外的归档条目没人管**。
3
+ * 文件类分区又是按归档名原样落盘的,于是「ZIP 有、checksums 没登记」的文件照样被写进目标目录,
4
+ * 完整性声明形同虚设(审计 docs/research/dsh-config-manager-technical-audit.md SEC-05)。
5
+ *
6
+ * 修后口径:
7
+ * - 会落盘的文件分区条目(Create/Update/Conflict)若不在 checksums 表内 ⇒ 计划项升级为 Error(逐条零写入),
8
+ * 整包仍 valid(表外 ≠ 被篡改,只留一条 warning);
9
+ * - 校验表**缺失或为空** ⇒ 一个条目都没被校验,文件分区条目同样逐条拦(RV1-F1,否则删表即绕过);
10
+ * - 历史包兼容开关 extraEntryExemptSections(默认空)按分区豁免,豁免分区退回「只告警」旧行为;
11
+ * - 「不在任何文件分区前缀下」的散落条目仍是告警(守 tests/conformance/roundtrip.test.ts INT-01)。
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ import { Analyzer } from './analyzer.ts';
20
+ import { Importer } from './importer.ts';
21
+ import { createAdapters } from '../adapters/index.ts';
22
+ import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
23
+ import { writeZip } from '../utils/zip.ts';
24
+ import { buildManifest } from '../schema/manifest.ts';
25
+ import { buildChecksums } from '../utils/hashing.ts';
26
+ import { SECTION_IDS, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.ts';
27
+ import { UNVERIFIED_FILE_ENTRY_CODE } from '../security/integrity.ts';
28
+ import type { SectionId } from '../schema/types.ts';
29
+ import type { HostContext } from './types.ts';
30
+
31
+ const SETTINGS = JSON.stringify({
32
+ version: 1,
33
+ namespaces: { llm: { value: { model: 'deepseek-chat' }, revision: 1, secrets: [] } },
34
+ });
35
+ const DECISIONS = { strategy: 'merge' as const, resolutions: {}, pathMappings: [] };
36
+ /** skills 分区在 ZIP 内的真实前缀(custom/skills/),从注册表派生,不写死布局。 */
37
+ const SKILL_PREFIX = SECTION_FILE_PREFIXES['skills']!;
38
+ const SKILL_REL = 'demo/SKILL.md';
39
+ const SKILL_ENTRY = SKILL_PREFIX + SKILL_REL;
40
+ /** 不在任何文件分区前缀下的散落条目(INT-01 用)。 */
41
+ const ROGUE_ENTRY = 'injected/rogue.json';
42
+
43
+ /** 造包:settings + skills 声明为包含;unlisted 里的条目进 ZIP 但不进 checksums 表。 */
44
+ async function buildBundle(
45
+ opts: { unlisted?: string[]; declareSkills?: boolean; checksums?: 'omit' | 'empty' } = {},
46
+ ): Promise<{ zipPath: string; tmp: string }> {
47
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcm-sec05-'));
48
+ const flags: Record<string, boolean> = {};
49
+ for (const sid of SECTION_IDS) flags[sid] = false;
50
+ flags['settings'] = true;
51
+ flags['skills'] = opts.declareSkills !== false;
52
+ const manifest = buildManifest({
53
+ exporterVersion: '0.1.0',
54
+ dshVersion: '0.1.54',
55
+ platform: 'win32',
56
+ arch: 'x64',
57
+ sections: flags as Record<SectionId, boolean>,
58
+ containsSecrets: false,
59
+ encrypted: false,
60
+ encryption: null,
61
+ });
62
+ const contentEntries = [
63
+ { name: SECTION_JSON_PATHS['settings']!, data: Buffer.from(SETTINGS, 'utf8') },
64
+ { name: SKILL_ENTRY, data: Buffer.from('# demo skill', 'utf8') },
65
+ { name: ROGUE_ENTRY, data: Buffer.from('{"rogue":true}', 'utf8') },
66
+ ];
67
+ const unlisted = new Set(opts.unlisted ?? []);
68
+ const checksums = buildChecksums(contentEntries.filter((e) => !unlisted.has(e.name)));
69
+ const entries = [
70
+ ...contentEntries,
71
+ { name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest), 'utf8') },
72
+ // RV1-F1:表缺失('omit')与表为空('empty')都必须逐条 fail-closed —— 两种形态曾被当成「没事」。
73
+ ...(opts.checksums === 'omit'
74
+ ? []
75
+ : [{
76
+ name: 'integrity/checksums.json',
77
+ data: Buffer.from(opts.checksums === 'empty' ? '{}' : JSON.stringify(checksums), 'utf8'),
78
+ }]),
79
+ ];
80
+ const zipPath = path.join(tmp, 'bundle.zip');
81
+ await writeZip(zipPath, entries);
82
+ return { zipPath, tmp };
83
+ }
84
+
85
+ function mkCtx(): HostContext {
86
+ return makeContext('linux', '/home/bob');
87
+ }
88
+
89
+ function mkAnalyzer(ctx: HostContext, extraEntryExemptSections?: readonly SectionId[]): Analyzer {
90
+ return new Analyzer({
91
+ ctx,
92
+ adapters: createAdapters({ namespaces: ['llm'] }),
93
+ snapshotStore: new MemSnapshotStore(),
94
+ ...(extraEntryExemptSections ? { extraEntryExemptSections } : {}),
95
+ });
96
+ }
97
+
98
+ function mkImporter(ctx: HostContext): Importer {
99
+ return new Importer({ ctx, adapters: createAdapters({ namespaces: ['llm'] }), snapshotStore: new MemSnapshotStore() });
100
+ }
101
+
102
+ test('SEC-05/① 对照:文件分区条目全量登记 → 不阻断,计划项照常 Create', async () => {
103
+ const { zipPath } = await buildBundle();
104
+ const ctx = mkCtx();
105
+ const importer = mkImporter(ctx);
106
+ const analysis = await importer.analyzeImport(zipPath);
107
+ assert.equal(analysis.valid, true, '全量登记不应阻断');
108
+ assert.deepEqual(analysis.errors, []);
109
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
110
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
111
+ assert.ok(item, 'skills 分区应有 SKILL.md 计划项');
112
+ assert.equal(item!.kind, 'Create');
113
+ });
114
+
115
+ test('SEC-05/② 表外文件分区条目 → 计划项 Error(零写入),analysis 只留一条 warning', async () => {
116
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
117
+ const ctx = mkCtx();
118
+ const importer = mkImporter(ctx);
119
+ const analysis = await importer.analyzeImport(zipPath);
120
+ // 表外 ≠ 被篡改:逐条 Error 已保证零写入,把整包判死只会拒绝整笔合法导入(含一个多余文件的历史包),
121
+ // 却没多拦住任何一次写盘 ⇒ valid 保持 true,错误面只留一条 warning 点名该条目。
122
+ assert.equal(analysis.valid, true, '表外条目不得把整包判死');
123
+ assert.deepEqual(analysis.errors, [], '表外条目不进 errors(不阻断整体导入)');
124
+ const named = analysis.warnings.some((w) => w.includes(SKILL_REL) || w.includes(SKILL_ENTRY));
125
+ assert.ok(named, 'warnings 必须点名该条目,实际: ' + JSON.stringify(analysis.warnings));
126
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
127
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
128
+ assert.ok(item, '被阻断条目仍要出现在计划里(用户能看到为什么没导)');
129
+ assert.equal(item!.kind, 'Error');
130
+ assert.equal(item!.severity, 'error');
131
+ assert.ok(item!.detail?.includes(UNVERIFIED_FILE_ENTRY_CODE), 'detail 应带错误码,实际: ' + String(item!.detail));
132
+ });
133
+
134
+ test('SEC-05/③ 按分区豁免 → 退回只告警(历史包兼容开关,默认关)', async () => {
135
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
136
+ const ctx = mkCtx();
137
+ const analyzer = mkAnalyzer(ctx, ['skills']);
138
+ const analysis = await analyzer.analyzeImport(zipPath);
139
+ assert.equal(analysis.valid, true, '豁免分区不得阻断');
140
+ assert.ok(analysis.warnings.some((w) => w.includes(SKILL_ENTRY)), '豁免也要留告警,不能静默放行');
141
+ const plan = await analyzer.createImportPlan(zipPath, DECISIONS);
142
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
143
+ assert.equal(item!.kind, 'Create', '豁免分区应照常落盘');
144
+ });
145
+
146
+ test('SEC-05/④ 非文件分区的散落条目仍只告警(守 INT-01 口径)', async () => {
147
+ const { zipPath } = await buildBundle({ unlisted: [ROGUE_ENTRY] });
148
+ const ctx = mkCtx();
149
+ const analysis = await mkImporter(ctx).analyzeImport(zipPath);
150
+ assert.equal(analysis.valid, true, '散落条目不阻断');
151
+ assert.deepEqual(analysis.errors, []);
152
+ assert.ok(analysis.warnings.some((w) => w.includes(ROGUE_ENTRY)), '散落条目必须有告警(不得静默忽略)');
153
+ });
154
+
155
+ test('SEC-05/⑤ 执行被阻断的计划 → 表外文件零落盘', async () => {
156
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
157
+ const ctx = mkCtx();
158
+ const importer = mkImporter(ctx);
159
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
160
+ const skillItem = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
161
+ assert.ok(skillItem, '被阻断条目应在计划里');
162
+ const result = await importer.executeImportPlan(zipPath, plan, { confirm: true });
163
+ const blocked = result.executed.find((e) => e.itemId === skillItem!.id);
164
+ assert.equal(blocked?.status, 'failed', '被阻断项执行结果必须是 failed');
165
+ assert.equal(await ctx.fs.exists(SKILL_ENTRY), false, '表外条目绝不能被写进目标');
166
+ });
167
+
168
+ /* ─── RV1-F1:表缺失 / 表为空 ⇒ 逐条 fail-closed(删掉表就等于关掉 SEC-05 的那个绕过) ─── */
169
+
170
+ async function expectFailClosed(mode: 'omit' | 'empty'): Promise<void> {
171
+ const { zipPath } = await buildBundle({ checksums: mode });
172
+ const ctx = mkCtx();
173
+ const importer = mkImporter(ctx);
174
+ const analysis = await importer.analyzeImport(zipPath);
175
+ // 逐条 fail-closed,不是整包拒:settings 这类非文件分区照常可导,判死的只是完整性管不到的文件分区条目。
176
+ assert.equal(analysis.valid, true, '表缺失/为空不得把整包判死(旧包仍要能用)');
177
+ assert.deepEqual(analysis.errors, []);
178
+ assert.ok(
179
+ analysis.warnings.some((w) => w.includes(SKILL_ENTRY)),
180
+ '没有校验表时,文件分区条目必须被点名(静默放行 = 防线失效),实际: ' + JSON.stringify(analysis.warnings),
181
+ );
182
+
183
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
184
+ const skillItem = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
185
+ assert.ok(skillItem, '文件分区条目应在计划里');
186
+ assert.equal(skillItem!.kind, 'Error', '没有校验表 ⇒ 文件分区条目一律未覆盖 ⇒ 必须拦');
187
+ assert.ok(String(skillItem!.detail ?? '').includes(UNVERIFIED_FILE_ENTRY_CODE), '拦下时必须带错误码');
188
+ assert.ok(
189
+ plan.items.some((i) => i.adapter === 'settings' && i.kind !== 'Error'),
190
+ '非文件分区(settings)不应被连带阻断',
191
+ );
192
+
193
+ const result = await importer.executeImportPlan(zipPath, plan, { confirm: true });
194
+ assert.equal(result.executed.find((e) => e.itemId === skillItem!.id)?.status, 'failed', '被拦项执行结果必须是 failed');
195
+ assert.equal(await ctx.fs.exists(SKILL_ENTRY), false, '没有校验表时,文件条目绝不能被写进目标');
196
+ }
197
+
198
+ test('RV1/F1⑥ 包内缺 integrity/checksums.json → 文件分区条目逐条拦、零写入(删表不能绕过 SEC-05)', async () => {
199
+ await expectFailClosed('omit');
200
+ });
201
+
202
+ test('RV1/F1⑦ 校验表为空 {} → 与缺失同口径(空表 = 一个条目都没校验)', async () => {
203
+ await expectFailClosed('empty');
204
+ });
205
+
@@ -47,7 +47,9 @@ test('i18n(ui): zhUiT 缺省 + makeUiT 双语插值', () => {
47
47
  assert.equal(zhUiT('error.notMounted'), 'config-manager 服务未挂载(插件未加载):请确认 profile 中已安装 dsh-config-manager 并重启 DSH');
48
48
  const enT = makeUiT('en');
49
49
  assert.equal(enT('error.notMounted'), 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH');
50
- assert.equal(enT('report.credentialsNeedEntry', { count: '3' }), '3 credential(s) need to be entered');
50
+ // 2026-11-01 起复数由 `{count, plural, ...}` 段决定:3 → credentials / need
51
+ assert.equal(enT('report.credentialsNeedEntry', { count: '3' }), '3 credentials need to be entered');
52
+ assert.equal(enT('report.credentialsNeedEntry', { count: '1' }), '1 credential needs to be entered');
51
53
  // 未知键回退 zh → 键名(类型层不允许字面量,这里用宽松断言验证运行期回退)
52
54
  const loose = makeUiT('en') as (key: string) => string;
53
55
  assert.equal(loose('no.such.key'), 'no.such.key');
@@ -65,6 +65,7 @@ export const zh = {
65
65
  'import.unsupportedSectionVersion': '分区 {section} 的数据版本 {version} 高于本版本支持的 1(已跳过该分区)',
66
66
  'import.checksumsMissing': '备份未提供完整性校验表,全部条目未被校验',
67
67
  'import.extraEntries': 'ZIP 内含未登记进校验表的条目: {entries}(未被校验)',
68
+ 'import.unverifiedFileEntry': '以下条目不在校验表内,无法校验,已逐条阻断写入(其余条目照常导入): {entries}',
68
69
  'import.undeclaredSectionContent': '备份包含未声明的分区内容(清单里标为不包含,这些条目不会被导入): {sections};示例条目: {entries}',
69
70
  'import.secretsPayloadEmpty': '备份声明携带凭据值,但解出的凭据清单为空:这不是「没有凭据」,而是没读到(请确认备份密码与导出机的凭据库布局)',
70
71
  'import.migrated': '备份 schema 已从 v{from} 迁移到 v{to}',
@@ -349,6 +350,7 @@ export const zh = {
349
350
  'adapter.workspaceSessionsOutsideBundle': '另有 {count} 个会话不在此备份包内(本次未导入其数据),未登记;目标机本来就有这些会话则不受影响',
350
351
  'adapter.workspaceSessionsManual': '本机工作区服务不支持会话登记,{count} 个会话未登记:启动 DSH 后可在设置里重新导入,或用离线修复工具 sessions repair',
351
352
  'adapter.workspaceSessionsOwnerMissing': '找不到对应的目标工作区记录,{count} 个会话未登记:请确认备份里包含该工作区(导出会话时会自动连带其工作区)',
353
+ 'adapter.selfImportBlocked': '包内含 {path}(同步目标配置):已拒绝写入,本机同步目标保持不变',
352
354
  'adapter.providerMissing': '导入数据缺少 provider {route}',
353
355
  'adapter.namespaceMissing': '导入数据缺少 namespace {ref}',
354
356
 
@@ -399,6 +401,7 @@ export const zh = {
399
401
  'sync.changesSummary': 'compatibility={compatibility},共 {count} 项变更',
400
402
  'sync.configWhitespace': '仓库地址不能包含空白字符',
401
403
  'sync.configUnparseable': '无法解析仓库地址: {url}',
404
+ 'sync.configPrivateHost': '仓库地址指向本机或内网地址,不作为同步目标: {url}',
402
405
  'sync.configUserinfo': '请勿在仓库地址中包含用户名/密码(认证 token 请使用凭据字段,不会拼入地址)',
403
406
  'sync.state.notObject': 'sync-state.json 损坏:必须是对象',
404
407
  'sync.state.schemaUnsupported': 'sync-state.json schemaVersion 不支持: {version}(期望 {expected})',
@@ -459,6 +462,7 @@ export const zh = {
459
462
  'sync.webdav.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
460
463
  'sync.pruneListFailed': '远端快照裁剪失败:无法列出远端快照,{reason}',
461
464
  'sync.pruneDeleteFailed': '远端快照裁剪失败:删除快照 {id} 失败,{reason}',
465
+ 'sync.gcSkipped': '本轮已放弃回收远端孤立文件({code}):本轮不做回收,快照本身不受影响',
462
466
 
463
467
  // ---------- 配置市场(m-market:只读公开仓库目录读取 + 校验) ----------
464
468
  'market.invalidItemId': '非法市场条目 id: {id}(仅允许字母数字开头,字符限 . _ -)',
@@ -571,6 +575,7 @@ export const en: Record<keyof typeof zh, string> = {
571
575
  'import.unsupportedSectionVersion': 'Section {section} data version {version} is higher than the supported version 1 (section skipped)',
572
576
  'import.checksumsMissing': 'Backup provides no integrity checksum table; no entry was verified',
573
577
  'import.extraEntries': 'ZIP contains entries not registered in the checksum table: {entries} (not verified)',
578
+ 'import.unverifiedFileEntry': 'These entries are not covered by the checksum table and cannot be verified, so each was blocked (the rest import normally): {entries}',
574
579
  'import.undeclaredSectionContent': 'Backup contains content for undeclared sections (not marked as included; these entries will not be imported): {sections}; sample entries: {entries}',
575
580
  'import.secretsPayloadEmpty': 'The backup declares embedded secret values, but no credential ref could be read out of it: this is not "no credentials", it means they were not read (check the backup password and the source machine credential-store layout)',
576
581
  'import.migrated': 'Backup schema migrated from v{from} to v{to}',
@@ -855,6 +860,7 @@ export const en: Record<keyof typeof zh, string> = {
855
860
  'adapter.workspaceSessionsOutsideBundle': 'A further {count} session(s) are not part of this backup (their data was not imported) and stay unregistered; sessions that already exist on this machine are unaffected',
856
861
  'adapter.workspaceSessionsManual': 'The host workspace service cannot attach sessions; {count} session(s) are unregistered: re-import from Settings once DSH is up, or use the offline tool: sessions repair',
857
862
  'adapter.workspaceSessionsOwnerMissing': 'No matching target workspace record, {count} session(s) unregistered: make sure the backup contains that workspace (exporting sessions now carries their workspaces automatically)',
863
+ 'adapter.selfImportBlocked': 'Package contains {path} (sync target config): write refused, local sync target unchanged',
858
864
  'adapter.providerMissing': 'Import data is missing provider {route}',
859
865
  'adapter.namespaceMissing': 'Import data is missing namespace {ref}',
860
866
 
@@ -905,6 +911,7 @@ export const en: Record<keyof typeof zh, string> = {
905
911
  'sync.changesSummary': 'compatibility={compatibility}, {count} change(s)',
906
912
  'sync.configWhitespace': 'Repository URL must not contain whitespace',
907
913
  'sync.configUnparseable': 'Cannot parse repository URL: {url}',
914
+ 'sync.configPrivateHost': 'Repository URL points at this machine or a private network, so it is not accepted as a sync target: {url}',
908
915
  'sync.configUserinfo': 'Do not include a username/password in the repository URL (use the credential field for the auth token; it is never embedded in the URL)',
909
916
  'sync.state.notObject': 'sync-state.json is corrupt: must be an object',
910
917
  'sync.state.schemaUnsupported': 'sync-state.json schemaVersion not supported: {version} (expected {expected})',
@@ -961,6 +968,7 @@ export const en: Record<keyof typeof zh, string> = {
961
968
  'sync.webdav.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
962
969
  'sync.pruneListFailed': 'Remote snapshot pruning failed: could not list remote snapshots, {reason}',
963
970
  'sync.pruneDeleteFailed': 'Remote snapshot pruning failed: deleting snapshot {id} failed, {reason}',
971
+ 'sync.gcSkipped': 'Skipped reclaiming remote orphan files this round ({code}); snapshots are unaffected',
964
972
 
965
973
  // ---------- config marketplace (m-market: read-only public repo directory read + validation) ----------
966
974
  'market.invalidItemId': 'Invalid marketplace item id: {id} (must start with an alphanumeric; only . _ - allowed)',
@@ -398,3 +398,36 @@ test('registerModelTools:tools 服务缺失 → 静默跳过(不抛错、不
398
398
  await fs.rm(tmp, { recursive: true, force: true })
399
399
  }
400
400
  })
401
+
402
+ /* ---------------- SEC-03 读侧可见性:被拒 repoUrl 的成因必须传到模型工具错误里 ---------------- */
403
+
404
+ /**
405
+ * 审计 SEC-01/02/03:`resolveEngine` 读到的 git 通道若因白名单被丢弃(repoUrl 是内网/环回/
406
+ * 带 userinfo/以 '-' 开头),修复前只抛「该通道未配置」——把「你配过但被拒」说成「你没配」,
407
+ * Agent 于是让用户「去配一下」,用户配了还是被拒,彻底卡死。
408
+ * 这里钉住:错误文本必须带上被拒地址与成因,且**绝不构造同步引擎**。
409
+ */
410
+ test('SEC-03 config_sync_push:已存 repoUrl 被白名单拒绝 → 错误带真实成因(不是「未配置」)', async () => {
411
+ const { deps, tmp, transport } = await makeDeps()
412
+ try {
413
+ await fs.mkdir(deps.syncDir, { recursive: true })
414
+ await fs.writeFile(
415
+ path.join(deps.syncDir, 'sync-config.json'),
416
+ JSON.stringify({ schemaVersion: 2, transport: 'git', git: { repoUrl: 'https://192.168.1.10/owner/repo.git' } }),
417
+ 'utf8',
418
+ )
419
+ const tools = createModelTools(deps)
420
+ await assert.rejects(
421
+ () => tools.syncPush({ channel: 'git' }),
422
+ (error: unknown) => {
423
+ const message = error instanceof Error ? error.message : String(error)
424
+ assert.ok(message.includes('192.168.1.10'), '错误必须带上被拒地址: ' + message)
425
+ assert.ok(/repo-url-invalid/.test(message), '错误必须带上机读成因码: ' + message)
426
+ return true
427
+ },
428
+ )
429
+ assert.ok(!transport.calls.includes('upload'), '被拒地址绝不发起任何远端写入')
430
+ } finally {
431
+ await fs.rm(tmp, { recursive: true, force: true })
432
+ }
433
+ })
@@ -37,7 +37,7 @@ import { createEncryptionProvider } from '../security/encryption.ts'
37
37
  import { SECTION_IDS } from '../schema/config.ts'
38
38
  import type { SectionId } from '../schema/types.ts'
39
39
  import { parseSyncChannel, readFullSyncConfig, readSyncConfigFor, SYNC_CHANNELS } from '../sync/sync-config.ts'
40
- import type { SyncConfig, SyncTransportType } from '../sync/sync-config.ts'
40
+ import type { SyncConfig, SyncConfigIssue, SyncTransportType } from '../sync/sync-config.ts'
41
41
  import type { ConfigAdapter, HostContext, SecretScanner } from './types.ts'
42
42
  import { runWithMutationLock } from '../utils/env-lock.ts'
43
43
 
@@ -92,17 +92,33 @@ function filterSectionIds(ids: readonly string[]): SectionId[] {
92
92
  return ids.filter((id): id is SectionId => (SECTION_IDS as readonly string[]).includes(id))
93
93
  }
94
94
 
95
+ /**
96
+ * SEC-03:把「读侧被白名单丢弃的配置」翻成一句可执行的补充说明。
97
+ * 只挑本通道的 issue —— git 地址被拒不该出现在 webdav 的错误里。
98
+ */
99
+ function describeConfigIssues(issues: readonly SyncConfigIssue[], channel: SyncTransportType): string {
100
+ const own = issues.filter((issue) => issue.channel === channel)
101
+ if (own.length === 0) return ''
102
+ return own.map((issue) => `config issue ${issue.code} (${issue.channel}): ${issue.message}`).join('; ')
103
+ }
104
+
95
105
  /** 解析同步引擎:channel 缺省取已配置的活动通道(git/webdav),未配置则抛可操作错误。 */
96
106
  async function resolveEngine(
97
107
  deps: ModelToolsDeps,
98
108
  channel?: SyncTransportType,
99
109
  ): Promise<{ engine: SyncEngine; channel: SyncTransportType }> {
110
+ // SEC-03:两处读取都收集 issues —— 否则「地址被安全校验拒掉」会被报成
111
+ // 「sync-config.json 缺失或损坏」,让模型/用户反复重填同一个被拒地址。
112
+ const issues: SyncConfigIssue[] = []
100
113
  // 未显式指定通道 → 取已配置的活动通道(唯一判定口径 parseSyncChannel;缺省 git)
101
- const full = channel === undefined ? await readFullSyncConfig(deps.syncDir) : null
114
+ const full = channel === undefined ? await readFullSyncConfig(deps.syncDir, issues) : null
102
115
  const ch: SyncTransportType = channel ?? parseSyncChannel(full?.transport) ?? 'git'
103
- const cfg = await readSyncConfigFor(deps.syncDir, ch)
116
+ const cfg = await readSyncConfigFor(deps.syncDir, ch, issues)
104
117
  if (cfg === null) {
105
- throw new Error(engineMsg('core.modelTools.syncChannelNotConfigured', { channel: ch }))
118
+ const base = engineMsg('core.modelTools.syncChannelNotConfigured', { channel: ch })
119
+ const detail = describeConfigIssues(issues, ch)
120
+ // 无 issue = 真的没配(保持原文案);有 issue = 说清成因,绝不吞掉
121
+ throw new Error(detail === '' ? base : `${base} — ${detail}`)
106
122
  }
107
123
  return { engine: deps.makeSyncEngine(cfg), channel: ch }
108
124
  }
package/src/index.ts CHANGED
@@ -132,7 +132,7 @@ import type { PruneSelector } from './core/backup.ts'
132
132
  import { selectPruneCandidates } from './core/backup.ts'
133
133
  import { appendAutosyncEntry } from './sync/sync-history.ts'
134
134
  import { MigrationStore, type MigrationKind, type MigrationResult, MIGRATION_HISTORY_DIR } from './core/migration-history.ts'
135
- import { readSyncConfig, validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.ts'
135
+ import { validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.ts'
136
136
  import {
137
137
  resolveSessionFormatDisposition,
138
138
  sessionFormatAbortResponse,
@@ -140,6 +140,7 @@ import {
140
140
  } from './routes/session-format.ts'
141
141
  import type { SessionFormatDisposition } from './core/types.ts'
142
142
  import type { SyncConfig, SyncTransportType } from './sync/sync-config.ts'
143
+ import { createPrepareSync } from './sync/config-issue-reporting.ts'
143
144
  import { AUTO_SYNC_OPT_IN_SECTIONS, defaultSyncSelection, effectiveSections, OPT_IN_SYNC_SECTIONS, readAllSyncSelections, readSyncSelection } from './sync/sync-selection.ts'
144
145
  import type { SyncSelection, SyncSelectionMode } from './sync/sync-selection.ts'
145
146
  import type { SyncTransport } from './sync/transport.ts'
@@ -180,7 +181,7 @@ export const name = 'config-manager'
180
181
  export const inject = ['settings', 'credentials']
181
182
 
182
183
  /** Plugin version, kept in sync with package.json ("version"). */
183
- export const PLUGIN_VERSION = '0.2.3'
184
+ export const PLUGIN_VERSION = '0.3.0'
184
185
 
185
186
  /** Plugin own package name — excluded from its own exported plugins list. */
186
187
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -2772,25 +2773,15 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2772
2773
  // credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
2773
2774
  // 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
2774
2775
 
2775
- /**
2776
- * 解析同步请求体并补全缺失字段(委托给导出的 parseSyncBody,便于单测)。
2777
- * username 回退:webdav 请求体未带 username(如挂载时 snapshotsList 自动加载、
2778
- * 表单留空后直接同步)时,从持久化 sync-config 回填已保存的 username——
2779
- * 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
2780
- * 语义与 password 一致:留空 = 沿用已保存凭据。
2781
- */
2782
- const prepareSync = async (body: Record<string, unknown>): Promise<SyncConfig> => {
2783
- const cfg = await parseSyncBody(body, { credentials })
2784
- if (isWebDavConfig(cfg) && (cfg.webdav.username === undefined || cfg.webdav.username === '')) {
2785
- try {
2786
- const persisted = await readSyncConfig(syncDir)
2787
- return mergePersistedWebDavUsername(cfg, persisted)
2788
- } catch {
2789
- return cfg // 读失败保持原值(空 username 由 WebDavTransport 构造校验兜底报错)
2790
- }
2791
- }
2792
- return cfg
2793
- }
2776
+ // 同步请求体准备 = 「webdav username 回填 + SEC-03 配置被拒的一次性告警」。
2777
+ // 解析/去重/文案与读侧语义都在 src/sync/config-issue-reporting.ts(含单测),
2778
+ // 这里只做装配:宿主只提供 credentials、日志出口与配置目录。
2779
+ const prepareSync = createPrepareSync({
2780
+ syncDir,
2781
+ parseBody: (body) => parseSyncBody(body, { credentials }),
2782
+ mergeWebDavUsername: mergePersistedWebDavUsername,
2783
+ log: (line) => host.log.warn(line),
2784
+ })
2794
2785
 
2795
2786
  /** 同步分区选择缓存(按通道;sync-selection.json;makeSyncEngine 同步读取用,保存路由更新)。
2796
2787
  * 缺失通道 = 尚未加载(启动竞态窗口);读取/使用处兜底 defaultSyncSelection。 */
@@ -10,6 +10,7 @@ import type { WebRoute } from './kit.ts'
10
10
  import type { RoutesEnv } from './context.ts'
11
11
  import type { RunState } from '../core/run-registry.ts'
12
12
  import type { ImportPlan } from '../core/types.ts'
13
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.ts'
13
14
  import { decryptErrorText, isControlledPath, tryDecryptCredentials, writeRequestBodyToFile } from './route-support.ts'
14
15
  import { resolveSessionFormatDisposition, sessionFormatAbortResponse } from './session-format.ts'
15
16
  import { ENCRYPTED_CONTAINER_CODE, SecurityError, decryptArchive, isArchiveBlob, readContainerKind, verifyEncryptedBlob } from '../security/index.ts'
@@ -252,7 +253,9 @@ export function importRoutes(env: RoutesEnv): WebRoute[] {
252
253
  opts['secretInputs'] !== null && typeof opts['secretInputs'] === 'object'
253
254
  ? opts['secretInputs'] as Record<string, string>
254
255
  : {},
255
- rollbackOnError: opts['rollbackOnError'] === true,
256
+ // IE-02:调用方没写就落全仓同一缺省,别在这里另立一份 true/false
257
+ rollbackOnError:
258
+ typeof opts['rollbackOnError'] === 'boolean' ? opts['rollbackOnError'] : DEFAULT_ROLLBACK_ON_ERROR,
256
259
  decryptedCredentials,
257
260
  // Phase 4 生产 snapshot 接线:deferred journal 绑定的 ctx 透传给引擎,
258
261
  // 使快照创建后立即 bindSnapshot(SNAPSHOT_CREATED)→ markApplying(APPLYING)再执行。