@logictan/dsh-config-manager 0.1.60 → 0.1.62

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 (77) hide show
  1. package/lib/adapters/credentials.d.ts +1 -1
  2. package/lib/adapters/index.d.ts +1 -1
  3. package/lib/adapters/index.js +1 -1
  4. package/lib/adapters/mcp.js +4 -4
  5. package/lib/adapters/plugins.d.ts +0 -1
  6. package/lib/adapters/plugins.js +35 -19
  7. package/lib/adapters/prompts.js +6 -6
  8. package/lib/adapters/test-helpers.d.ts +27 -0
  9. package/lib/adapters/test-helpers.js +40 -0
  10. package/lib/client.d.ts +1 -13
  11. package/lib/client.js +297 -304
  12. package/lib/core/analyzer.js +5 -4
  13. package/lib/core/backup.js +5 -3
  14. package/lib/core/exporter.d.ts +1 -4
  15. package/lib/core/exporter.js +14 -45
  16. package/lib/core/importer.d.ts +1 -1
  17. package/lib/core/index.d.ts +1 -1
  18. package/lib/core/messages.d.ts +1 -5
  19. package/lib/core/messages.js +2 -10
  20. package/lib/core/patch-layers.d.ts +32 -0
  21. package/lib/core/patch-layers.js +39 -0
  22. package/lib/core/rollback.js +4 -4
  23. package/lib/core/types.d.ts +8 -12
  24. package/lib/index.d.ts +23 -14
  25. package/lib/index.js +31 -92
  26. package/lib/security/index.d.ts +1 -3
  27. package/lib/security/index.js +1 -3
  28. package/lib/sync/transport.d.ts +2 -2
  29. package/lib/sync/transport.js +1 -1
  30. package/lib/ui/i18n.d.ts +0 -3
  31. package/lib/ui/i18n.js +0 -6
  32. package/lib/ui/report.js +1 -2
  33. package/lib/ui/test-helpers.d.ts +0 -6
  34. package/lib/ui/test-helpers.js +1 -5
  35. package/lib/ui/types.d.ts +0 -13
  36. package/lib/utils/env-lock.d.ts +16 -0
  37. package/lib/utils/env-lock.js +27 -6
  38. package/package.json +2 -2
  39. package/src/adapters/credentials.ts +1 -1
  40. package/src/adapters/index.ts +1 -1
  41. package/src/adapters/mcp.ts +4 -4
  42. package/src/adapters/patch-layers.test.ts +201 -0
  43. package/src/adapters/plugins.test.ts +13 -5
  44. package/src/adapters/plugins.ts +33 -17
  45. package/src/adapters/prompts.ts +6 -6
  46. package/src/adapters/test-helpers.ts +48 -0
  47. package/src/client/api.ts +1 -10
  48. package/src/client/config-manager.module.css +1 -1
  49. package/src/client/run-store.test.ts +1 -1
  50. package/src/client/sync/SyncSettingsView.tsx +5 -5
  51. package/src/client/sync/sync-api.ts +0 -1
  52. package/src/client/sync/sync-locales.ts +0 -1
  53. package/src/core/analyzer.ts +5 -4
  54. package/src/core/backup.ts +5 -3
  55. package/src/core/config-lifecycle.test.ts +4 -1
  56. package/src/core/exporter.ts +15 -47
  57. package/src/core/importer.ts +1 -1
  58. package/src/core/index.ts +1 -1
  59. package/src/core/messages.ts +2 -10
  60. package/src/core/patch-layers.ts +42 -0
  61. package/src/core/rollback.ts +4 -4
  62. package/src/core/smoke.test.ts +5 -5
  63. package/src/core/types.ts +8 -12
  64. package/src/index.facade.test.ts +25 -2
  65. package/src/index.ts +30 -104
  66. package/src/security/index.ts +1 -3
  67. package/src/security/security.test.ts +0 -361
  68. package/src/sync/transport.ts +3 -3
  69. package/src/ui/i18n.ts +0 -6
  70. package/src/ui/report.ts +1 -2
  71. package/src/ui/test-helpers.ts +3 -7
  72. package/src/ui/types.ts +0 -10
  73. package/src/utils/env-lock.test.ts +29 -0
  74. package/src/utils/env-lock.ts +27 -6
  75. package/lib/security/encryption.d.ts +0 -85
  76. package/lib/security/encryption.js +0 -279
  77. package/src/security/encryption.ts +0 -335
@@ -3,11 +3,9 @@
3
3
  * adapter 收集各分区 → Secret 过滤 → manifest → checksum → ZIP。
4
4
  *
5
5
  * 安全不变量:
6
- * - Secret 值默认永不进入导出数据(结构化分区逐一过 SecretScanner);
7
- * - includeSecrets=true 必须注入 EncryptionProvider(m4 实现),否则拒绝导出;
8
- * - 注入 EncryptionProvider 时备份标记为加密(encrypted=true):includeSecrets=false
9
- * 时 secrets.enc 加密空内容占位,备份仍需要密码导入,但不含任何凭据值;
10
- * - 加密密码/秘密值绝不写入 manifest 与日志。
6
+ * - Secret 值默认永不进入导出数据(结构化分区逐一过 SecretScanner 剥离值);
7
+ * - 备份恒为明文(encrypted=false / encryption=null):本插件不再有加密层;
8
+ * - 秘密值绝不写入 manifest 与日志。
11
9
  */
12
10
  import fs from 'node:fs/promises';
13
11
  import path from 'node:path';
@@ -21,7 +19,7 @@ import { msgOf } from './messages.ts';
21
19
  import type { MsgFunc } from './messages.ts';
22
20
  import type { Manifest, SectionId } from '../schema/types.ts';
23
21
  import type {
24
- ConfigAdapter, EncryptionProvider, ExportOptions, ExportReport,
22
+ ConfigAdapter, ExportOptions, ExportReport,
25
23
  ExportSection, HostContext, SecretScanner, SensitiveHit,
26
24
  } from './types.ts';
27
25
 
@@ -39,8 +37,6 @@ export interface ExporterOptions {
39
37
  adapters: ConfigAdapter[];
40
38
  /** Secret 扫描器;缺省用字段名黑名单剥离(m4 可注入强化版) */
41
39
  scanner?: SecretScanner;
42
- /** 加密提供者(m4 用 node:crypto 实现);includeSecrets 时必填;提供时备份标记 encrypted=true */
43
- encryption?: EncryptionProvider | null;
44
40
  /** 插件自身版本(manifest.exporter.version) */
45
41
  exporterVersion?: string;
46
42
  now?: () => Date;
@@ -176,7 +172,6 @@ export class Exporter {
176
172
  private readonly ctx: HostContext;
177
173
  private readonly adapters: ConfigAdapter[];
178
174
  private readonly scanner: SecretScanner;
179
- private readonly encryption: EncryptionProvider | null;
180
175
  private readonly exporterVersion: string;
181
176
  private readonly now: () => Date;
182
177
  private readonly msg: MsgFunc;
@@ -187,7 +182,6 @@ export class Exporter {
187
182
  this.ctx = opts.ctx;
188
183
  this.adapters = opts.adapters;
189
184
  this.scanner = opts.scanner ?? defaultSecretScanner();
190
- this.encryption = opts.encryption ?? null;
191
185
  this.exporterVersion = opts.exporterVersion ?? '0.1.0';
192
186
  this.now = opts.now ?? (() => new Date());
193
187
  this.msg = opts.msg ?? msgOf(opts.ctx);
@@ -201,9 +195,6 @@ export class Exporter {
201
195
  */
202
196
  async export(options: ExportOptions): Promise<{ zipPath: string; manifest: Manifest; report: ExportReport }> {
203
197
  const { includeSecrets, only } = options;
204
- if (includeSecrets && !this.encryption) {
205
- throw new Error(this.msg('export.encryptionRequired'));
206
- }
207
198
 
208
199
  // 1. 选定分区(only 过滤 + 默认包含)
209
200
  const selected = this.adapters
@@ -214,6 +205,8 @@ export class Exporter {
214
205
  const sections: ExportSection[] = [];
215
206
  const warnings: string[] = [];
216
207
  const redactedHits: SensitiveHit[] = [];
208
+ /** 文件类分区实扫到的命中数:只有它能让 containsSecrets 为真(结构化分区已被剥离) */
209
+ let fileSectionSecretHits = 0;
217
210
  const included: ExportReport['included'] = [];
218
211
  const excluded: SectionId[] = this.adapters.filter((a) => !selected.includes(a.id)).map((a) => a.id);
219
212
 
@@ -249,6 +242,7 @@ export class Exporter {
249
242
  if (fileHits.length > 0) {
250
243
  // redactedHits 是**报告统计**通道(非告警通道):仍计入全量命中(含同一文件的多行/多形态命中)。
251
244
  redactedHits.push(...fileHits);
245
+ fileSectionSecretHits += fileHits.length;
252
246
  // 告警按**文件**去重(G-09/H1):同一路径只告警一次。
253
247
  // 修复前按 hit 计数,同一行同时命中「字段名」与「值形状」会产出两条同路径告警,
254
248
  // 使少数文件就吃满上限,导致含真实明文凭据的其它文件被静默淹没(实测 redactedHits=8 而 5 条告警全属一个文件)。
@@ -278,12 +272,13 @@ export class Exporter {
278
272
  warnings.push(...section.warnings);
279
273
  }
280
274
 
281
- // 4. 组装 ZIP 条目(JSON 分区 + 文件类分区 + secrets.enc + checksums + manifest)
275
+ // 4. 组装 ZIP 条目(JSON 分区 + 文件类分区 + checksums + manifest)
282
276
  const entries: { name: string; data: Uint8Array }[] = [];
283
277
  const sectionFlags = buildSectionFlags(sections);
284
- let containsSecrets = false;
285
- let encrypted = false;
286
- let encryption: Manifest['security']['encryption'] = null;
278
+ // 备份恒为明文:本插件不再有加密层。containsSecrets 只认「文件类分区实扫到的命中」——
279
+ // 结构化分区的敏感值已被 scanner 剥离,不构成「含秘密」;文件类分区只报告不改写,
280
+ // 命中即代表归档里确有明文,必须如实标注。
281
+ const containsSecrets = fileSectionSecretHits > 0;
287
282
 
288
283
  for (const section of sections) {
289
284
  if (isFileSection(section.sectionId)) {
@@ -302,33 +297,8 @@ export class Exporter {
302
297
  });
303
298
  }
304
299
 
305
- // secrets.enc:有加密提供者即生成。includeSecrets=true 时加密真实的凭据原文;
306
- // 只勾选加密(不导出密钥)时加密空内容占位,备份仍标记 encrypted(导入需密码),
307
- // 但绝不把凭据值放进去(containsSecrets 保持 false;安全不变量不破)。
308
- if (this.encryption) {
309
- const credentialsFile = path.join(this.ctx.homeDir, '.credentials.yaml');
310
- let plaintext: string;
311
- if (includeSecrets) {
312
- try {
313
- const raw = await this.ctx.fs.readFile(credentialsFile);
314
- plaintext = Buffer.from(raw).toString('utf8');
315
- } catch (err) {
316
- warnings.push(this.msg('export.credentialsReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
317
- plaintext = '';
318
- }
319
- } else {
320
- plaintext = '';
321
- }
322
- const result = await this.encryption.encrypt(plaintext);
323
- entries.push({ name: 'security/secrets.enc', data: result.blob });
324
- encryption = result.info;
325
- containsSecrets = includeSecrets && plaintext !== '';
326
- encrypted = true;
327
- }
328
-
329
300
  // 4b. 文件级 vault(includeSecrets=false:敏感文件明文不进归档 → 镜像到本机 vault)。
330
- // 尽力而为:任何失败仅记警告,不中断导出。includeSecrets=true 时秘密已加密进归档,
331
- // 无需镜像(vault 只服务于「明文不进备份」的本机留存场景)。
301
+ // 尽力而为:任何失败仅记警告,不中断导出。
332
302
  let vaultRefreshed = 0;
333
303
  if (!includeSecrets) {
334
304
  try {
@@ -357,8 +327,8 @@ export class Exporter {
357
327
  arch: this.ctx.arch,
358
328
  sections: sectionFlags,
359
329
  containsSecrets,
360
- encrypted,
361
- encryption,
330
+ encrypted: false,
331
+ encryption: null,
362
332
  exportedAt: this.now().toISOString(),
363
333
  });
364
334
  entries.push({ name: MANIFEST_FILE, data: Buffer.from(stringifyJsonSafe(manifest, { space: 2 }), 'utf8') });
@@ -375,7 +345,6 @@ export class Exporter {
375
345
  sections: Object.keys(sectionFlags).filter((k) => sectionFlags[k as SectionId]),
376
346
  redactedFields: redactedHits.length,
377
347
  containsSecrets,
378
- encrypted,
379
348
  vaultRefreshed,
380
349
  });
381
350
 
@@ -385,7 +354,6 @@ export class Exporter {
385
354
  security: {
386
355
  secretsExcluded: !includeSecrets,
387
356
  containsSecrets,
388
- encrypted,
389
357
  redactedHits: redactedHits.length,
390
358
  vaultRefreshed,
391
359
  },
@@ -36,7 +36,7 @@ export interface ExecuteOptions {
36
36
  confirm: boolean;
37
37
  /** 用户补录的秘密值(仅内存) */
38
38
  secretInputs?: Record<string, string>;
39
- /** 加密备份解密结果(仅内存;解密必须经 m4 encryption provider) */
39
+ /** 旧版加密备份的解密结果(仅内存;宿主解密后注入,本插件不再产生加密备份) */
40
40
  decryptedCredentials?: Map<string, string>;
41
41
  /** 任一项失败立即整体回滚(默认 false:单项失败如实记录并继续其余项) */
42
42
  rollbackOnError?: boolean;
package/src/core/index.ts CHANGED
@@ -47,7 +47,7 @@ export type {
47
47
  ExportOptions, ExportSection, ValidationResult, HostContext,
48
48
  SettingsFacade, CredentialsFacade, PluginsFacade, WorkspaceFacade,
49
49
  PatchFileFacade, FileSystemFacade, NamespaceInfo, PluginInfo,
50
- ConfigAdapter, Portability, SecretScanner, SensitiveHit, EncryptionProvider,
50
+ ConfigAdapter, Portability, SecretScanner, SensitiveHit,
51
51
  PlanItem, PlanItemKind, ItemResolution, GlobalConflictStrategy,
52
52
  ImportAnalysis, ImportDecisions, ImportPlan, ImportResult, ExecutedItem,
53
53
  ImportContext, SnapshotTarget, SnapshotEntry, Snapshot, SnapshotStore,
@@ -18,9 +18,7 @@ export type { MsgFunc, MsgParams } from './msg-types.ts';
18
18
 
19
19
  export const zh = {
20
20
  // ---------- 导出 ----------
21
- 'export.encryptionRequired': '导出包含秘密需要注入 EncryptionProvider(m4 实现),拒绝明文导出秘密',
22
21
  'export.sectionFailed': '分区 {adapter} 导出失败: {reason}',
23
- 'export.credentialsReadFailed': '读取凭据文件失败,跳过秘密导出: {reason}',
24
22
  'export.vaultRefreshed': '凭据明文未进入备份,已镜像到本机 vault({count} 个文件,恢复时可回填)',
25
23
  'export.vaultRefreshSkipped': 'vault 镜像跳过 {rel}: {reason}',
26
24
  'export.vaultRefreshFailed': 'vault 镜像刷新失败(不影响导出): {reason}',
@@ -53,9 +51,7 @@ export const zh = {
53
51
  'import.secretMissingDesc': '凭据 {ref} 需要补录',
54
52
  'import.secretNotProvided': '凭据未提供,需补录',
55
53
  'import.notConfirmed': '导入未确认:必须显式 confirm 后才允许修改任何数据',
56
- 'import.encryptedPasswordRequired': '该备份已加密,必须提供解密密码才能导入(拒绝无密码导入)',
57
- 'import.encryptedPasswordWrong': '解密密码错误,请重试',
58
- 'import.notEncryptedContainer': '该文件不是加密备份容器,无法解锁',
54
+ 'import.encryptedPasswordRequired': '该备份含上游历史加密凭据(security.encrypted=true),本插件无解密能力:须由宿主解密后注入凭据(decryptedCredentials)才能导入',
59
55
  'import.userSkipped': '用户跳过(导入中点击「跳过当前插件」)',
60
56
  'import.userSkippedDetail': '插件 {name} 已由用户跳过,未安装',
61
57
  'import.vaultRestored': '凭据文件 {rel} 已从本机 vault 回填',
@@ -327,9 +323,7 @@ export const zh = {
327
323
 
328
324
  export const en: Record<keyof typeof zh, string> = {
329
325
  // ---------- export ----------
330
- 'export.encryptionRequired': 'Encrypted export requires an EncryptionProvider (m4); refusing to export secrets in plaintext',
331
326
  'export.sectionFailed': 'Section {adapter} export failed: {reason}',
332
- 'export.credentialsReadFailed': 'Failed to read the credentials file; secrets export skipped: {reason}',
333
327
  'export.vaultRefreshed': 'Credential plaintext did not enter the backup; mirrored to the local vault ({count} file(s), available for restore backfill)',
334
328
  'export.vaultRefreshSkipped': 'Vault mirror skipped {rel}: {reason}',
335
329
  'export.vaultRefreshFailed': 'Vault mirror refresh failed (export unaffected): {reason}',
@@ -362,9 +356,7 @@ export const en: Record<keyof typeof zh, string> = {
362
356
  'import.secretMissingDesc': 'Credential {ref} needs to be re-entered',
363
357
  'import.secretNotProvided': 'Credential not provided; requires re-entry',
364
358
  'import.notConfirmed': 'Import not confirmed: explicit confirm is required before any data is modified',
365
- 'import.encryptedPasswordRequired': 'This backup is encrypted; the decryption password is required to import it (refusing password-less import)',
366
- 'import.encryptedPasswordWrong': 'Wrong decryption password, please try again',
367
- 'import.notEncryptedContainer': 'This file is not an encrypted backup container and cannot be unlocked',
359
+ 'import.encryptedPasswordRequired': 'This backup carries upstream-encrypted credentials (security.encrypted=true) and this plugin cannot decrypt them: a host must decrypt and inject them (decryptedCredentials) before import',
368
360
  'import.userSkipped': 'Skipped by user (clicked "Skip current plugin" during import)',
369
361
  'import.userSkippedDetail': 'Plugin {name} was skipped by the user and not installed',
370
362
  'import.vaultRestored': 'Credential file {rel} backfilled from the local vault',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * cordis patch 层的寻址契约(唯一真源)。
3
+ *
4
+ * 宿主组装 patch 栈的顺序是 `bundle → profile → home → --patch`(后者覆盖前者),两层分工不同:
5
+ * - home 层 `$DSH_HOME/cordis.patch.yml`:全机偏好,对每个 profile 生效;
6
+ * - profile 层 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`:该 profile 专属(端口、trustedHosts、
7
+ * 挂载行…),换机恢复时必须跟着走。
8
+ *
9
+ * 两层的**持久化标识**是这里的两个逻辑 token,而不是相对路径:同步快照会跨机器、
10
+ * 跨 profile 导入,相对路径会把源机的 profile 名带过去。home 层 token 与历史快照里的
11
+ * `PatchLine.file` 取值逐字相同(存量快照零改动即可解析回 home 层)。
12
+ *
13
+ * 层限定复合键 `<file>#<lineId>`:两层可以存在同名 `lineId`,而计划项 id / `target.ref` /
14
+ * 快照条目必须唯一指向「哪一层的哪一行」,否则应用与回滚会写错层。两个 token 与 lineId 都不含
15
+ * `#`(后者由 `readPatchLines` 保证非空字符串,此处不额外校验),分隔符无歧义。
16
+ */
17
+
18
+ /** home 层 token(`$DSH_HOME/cordis.patch.yml`);取值与历史快照的 file 字段一致。 */
19
+ export const HOME_PATCH_FILE = 'cordis.patch.yml';
20
+
21
+ /** profile 层 token(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)。 */
22
+ export const PROFILE_PATCH_FILE = 'profile:cordis.patch.yml';
23
+
24
+ /** 层限定复合键的分隔符(两个层 token 与 lineId 都不含它)。 */
25
+ const PATCH_LAYER_SEPARATOR = '#';
26
+
27
+ /** 层限定复合键:`<file>#<lineId>`。 */
28
+ export function patchLayerKey(file: string, lineId: string): string {
29
+ return `${file}${PATCH_LAYER_SEPARATOR}${lineId}`;
30
+ }
31
+
32
+ /**
33
+ * 解析层限定复合键。
34
+ *
35
+ * 兼容分支:旧快照的 `target.ref` / `SnapshotEntry.ref` 是裸 `lineId`(当时只有 home 层),
36
+ * 不含 `#` 即视作 home 层。lineId 本身不含 `#`,因此该分支无歧义。
37
+ */
38
+ export function parsePatchLayerKey(ref: string): { file: string; lineId: string } {
39
+ const at = ref.indexOf(PATCH_LAYER_SEPARATOR);
40
+ if (at < 0) return { file: HOME_PATCH_FILE, lineId: ref };
41
+ return { file: ref.slice(0, at), lineId: ref.slice(at + 1) };
42
+ }
@@ -8,6 +8,7 @@
8
8
  * - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
9
9
  */
10
10
  import { resolveFileTarget } from './backup.ts';
11
+ import { parsePatchLayerKey } from './patch-layers.ts';
11
12
  import { msgOf } from './messages.ts';
12
13
  import type {
13
14
  ConfigAdapter, HostContext, RollbackReport, Snapshot, SnapshotEntry, SnapshotStore,
@@ -71,10 +72,9 @@ async function compensateOne(
71
72
  }
72
73
  case 'patchLine': {
73
74
  try {
74
- // 引擎只管理 profile 的 cordis.patch.yml(backup.ts 的 patchLine 快照只记 lineId 作 ref,
75
- // 不含文件编码)——回滚固定写回该文件;切勿把 lineId 当文件名(否则 patchPath 抛「仅支持管理」)。
76
- const file = 'cordis.patch.yml';
77
- const lineId = entry.ref;
75
+ // 快照条目的 ref 是层限定复合键(<file>#<lineId>)——必须写回**同一层**,
76
+ // 否则 profile 层的原值会被写进 home 层。旧快照的裸 lineId 兼容为 home 层。
77
+ const { file, lineId } = parsePatchLayerKey(entry.ref);
78
78
  await ctx.patchFile.applyPatchChanges(file, [
79
79
  { lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
80
80
  ]);
@@ -827,15 +827,15 @@ test('兼容性评分规则', () => {
827
827
  assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.6', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'win32', schemaVersion: 999, missingSections: [] }), 'unsupported');
828
828
  });
829
829
 
830
- test('包含秘密导出:无加密提供者时拒绝(绝不明文泄密)', async () => {
830
+ test('包含秘密导出:本插件无加密层,明文导出照常进行且备份恒标记未加密', async () => {
831
831
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-sec-'));
832
832
  try {
833
833
  const src = makeContext('win32', 'C:\\Users\\alice');
834
834
  const exporter = new Exporter({ ctx: src, adapters: makeAdapters(), now: () => new Date() });
835
- await assert.rejects(
836
- () => exporter.export({ includeSecrets: true, outPath: path.join(tmp, 'x.zip') }),
837
- /EncryptionProvider/,
838
- );
835
+ const { manifest, report } = await exporter.export({ includeSecrets: true, outPath: path.join(tmp, 'x.zip') });
836
+ assert.equal(manifest.security.encrypted, false, '备份恒为明文');
837
+ assert.equal(manifest.security.encryption, null, '不存在加密参数');
838
+ assert.equal(report.security.secretsExcluded, false, 'includeSecrets=true 时不走 vault 留存路径');
839
839
  } finally {
840
840
  await fs.rm(tmp, { recursive: true, force: true });
841
841
  }
package/src/core/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * HostContext 是 m3 定义、m5 实现的 DSH Service 门面(研究报告 §3.2 的叶子方法最小集),
6
6
  * 测试用内存 mock 即可驱动完整导出→导入往返。
7
7
  */
8
- import type { EncryptionInfo, Manifest, SectionId, WorkspaceRecord } from '../schema/types.ts';
8
+ import type { Manifest, SectionId, WorkspaceRecord } from '../schema/types.ts';
9
9
  import type { TombstoneKind } from '../schema/tombstones.ts';
10
10
  import type { MutationLockPort } from '../utils/env-lock.ts';
11
11
  import type { RecursiveListing } from '../utils/recursive-walk.ts';
@@ -16,7 +16,11 @@ import type { MsgFunc } from './messages.ts';
16
16
  /* ---------------- 导出选项与分区产出 ---------------- */
17
17
 
18
18
  export interface ExportOptions {
19
- /** 是否包含真实秘密(必须配合 encryption 提供者;缺省 false = 只导状态) */
19
+ /**
20
+ * 是否把真实秘密写入备份。本插件不再有加密层(备份恒为明文),结构化分区的秘密值
21
+ * 始终由 SecretScanner 剥离,故本开关当前的实际作用只剩「是否刷新本机 vault 镜像」:
22
+ * false → 导出后把敏感文件镜像到本机 vault(明文不进归档)。
23
+ */
20
24
  includeSecrets: boolean;
21
25
  /** 仅导出指定分区(缺省 = 全部默认包含分区) */
22
26
  only?: SectionId[];
@@ -235,7 +239,7 @@ export interface ImportAnalysis {
235
239
  pathIssues: PathIssue[];
236
240
  secretCount: number;
237
241
  dependencyIssues: { item: string; dependency: string }[];
238
- /** 备份是否加密(manifest.security.encrypted):加密备份的凭据必须用解密密码恢复 */
242
+ /** 旧版加密备份标记(manifest.security.encrypted):本插件不再产生,仅历史产物为 true */
239
243
  encrypted: boolean;
240
244
  }
241
245
 
@@ -298,6 +302,7 @@ export interface ImportContext {
298
302
  resolutions: Record<string, ItemResolution>;
299
303
  /** 用户补录的秘密值(仅内存,永不落盘/日志) */
300
304
  secretInputs: Record<string, string>;
305
+ /** 旧版加密备份的解密结果(仅内存;宿主解密后注入) */
301
306
  decryptedCredentials?: Map<string, string>;
302
307
  log: Logger;
303
308
  /** 消息翻译器(analyzer 注入;适配器用它生成计划项描述/校验/结果消息) */
@@ -466,7 +471,6 @@ export interface ExportReport {
466
471
  security: {
467
472
  secretsExcluded: boolean;
468
473
  containsSecrets: boolean;
469
- encrypted: boolean;
470
474
  redactedHits: number;
471
475
  /** 本次导出镜像到本机 vault 的敏感文件数(文件级 vault;0 或缺失 = 未镜像) */
472
476
  vaultRefreshed?: number;
@@ -504,14 +508,6 @@ export interface ConfigAdapter<TSection = unknown> {
504
508
  rollback?(entries: SnapshotEntry[], ctx: HostContext): Promise<void>;
505
509
  }
506
510
 
507
- /* ---------------- 加密提供者(m4 用 node:crypto 实现) ---------------- */
508
-
509
- export interface EncryptionProvider {
510
- encrypt(plaintext: string): Promise<{ blob: Uint8Array; info: EncryptionInfo }>;
511
- /** authTag 校验失败必须抛错 */
512
- decrypt(blob: Uint8Array, info: EncryptionInfo, password: string): Promise<string>;
513
- }
514
-
515
511
  /* ---------------- 错误类型 ---------------- */
516
512
 
517
513
  /** 导入被确认前拒绝执行(安全阀) */
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import test from 'node:test';
12
12
  import assert from 'node:assert/strict';
13
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
- import { DshPluginsFacade, ensureActivationRow } from './index.ts';
17
+ import { DshPatchFileFacade, DshPluginsFacade, ensureActivationRow } from './index.ts';
18
18
  import { resolveProfileDir } from './core/plugin-cli.ts';
19
19
  import type { DshPluginResult } from './core/plugin-cli.ts';
20
20
  import type { PatchChange, PatchFileFacade } from './core/types.ts';
@@ -250,6 +250,29 @@ test('ensureActivationRow: bundle 包跳过(reconcile 已维护 bundles)', a
250
250
  }
251
251
  });
252
252
 
253
+ test('ensureActivationRow: 非 bundle 插件补行落在 profile 层(home 层文件不受影响)', async () => {
254
+ const { homeDir, profileDir, cleanup } = makeTempProfile();
255
+ try {
256
+ writeInstalledPkg(profileDir, 'pkg-a', '1.0.0');
257
+ const patchFile = new DshPatchFileFacade(homeDir, 'web');
258
+ const homePatch = join(homeDir, 'cordis.patch.yml');
259
+ const profilePatch = join(profileDir, 'cordis.patch.yml');
260
+ writeFileSync(homePatch, '- id: home-line\n', 'utf8');
261
+ const homeBefore = readFileSync(homePatch, 'utf8');
262
+
263
+ await ensureActivationRow(patchFile, join(profileDir, 'node_modules', 'pkg-a'), 'pkg-a');
264
+
265
+ assert.match(
266
+ readFileSync(profilePatch, 'utf8'),
267
+ /id: pm-pkg-a/,
268
+ '激活行必须落在 profile 层(home 层对每个 profile 生效,会让只在 web 装过的插件污染其他 profile)',
269
+ );
270
+ assert.equal(readFileSync(homePatch, 'utf8'), homeBefore, 'home 层文件必须原样不变');
271
+ } finally {
272
+ cleanup();
273
+ }
274
+ });
275
+
253
276
  test('ensureActivationRow: scope 包名 slug 形态正确(@scope/name → pm-scope-name)', async () => {
254
277
  const { profileDir, cleanup } = makeTempProfile();
255
278
  try {
package/src/index.ts CHANGED
@@ -20,8 +20,8 @@
20
20
  * (isLoopbackRequest); LAN-exposed deployments never serve these endpoints;
21
21
  * - uploads/exported ZIPs are staged under $DSH_HOME/dsh-config-manager/{tmp,exports}
22
22
  * and every `path`/`zipPath` reference is confined to those roots;
23
- * - the encryption password is in-memory only: used to derive the AES-256-GCM
24
- * key for secrets.enc, never written to any file, manifest, or log;
23
+ * - there is no encryption layer: every backup is plaintext, and secret values are
24
+ * stripped by the secret scanner before they reach any file, manifest, or log;
25
25
  * - the import execute endpoint refuses to run without `confirm: true`
26
26
  * (core ImportNotConfirmedError safety valve).
27
27
  *
@@ -84,7 +84,7 @@ import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
84
84
  import type { MsgFunc } from './core/messages.ts'
85
85
  import {
86
86
  cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins,
87
- resolveProfileDir, resolveProfileNameFromArgv, readProfileManifest, runDshPlugin, validateProfileName,
87
+ resolveProfileDir, resolveProfileNameFromArgv, runDshPlugin, validateProfileName,
88
88
  } from './core/plugin-cli.ts'
89
89
  import type {
90
90
  ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
@@ -92,9 +92,9 @@ import type {
92
92
  SettingsFacade, Snapshot, WorkspaceFacade,
93
93
  } from './core/types.ts'
94
94
  import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.ts'
95
- import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
95
+ import { createAdapters } from './adapters/index.ts'
96
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE } from './core/patch-layers.ts'
96
97
  import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
97
- import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.ts'
98
98
  import { createHardenedZipParser } from './security/zip-security.ts'
99
99
  import { atomicCopyFile, atomicWriteFile } from './utils/atomic-write.ts'
100
100
  import { EnvironmentLockManager, runWithMutationLock, EnvironmentLockUnavailableError, type MutationLockContext } from './utils/env-lock.ts'
@@ -139,11 +139,10 @@ import { createConfiguredSecretScanner } from './security/secret-scanner.ts'
139
139
  import type { ConfiguredSecretPatterns } from './security/secret-scanner.ts'
140
140
  import type { SecretScanner } from './core/types.ts'
141
141
  import { sha256Hex } from './utils/hashing.ts'
142
- import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
143
142
  import { isFileSection, SECTION_IDS } from './schema/config.ts'
144
143
  import { stringifyJsonSafe } from './utils/json.ts'
145
- import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
146
- import { parseZip, zipToBuffer } from './utils/zip.ts'
144
+ import type { SectionId, WorkspaceRecord } from './schema/types.ts'
145
+ import { zipToBuffer } from './utils/zip.ts'
147
146
  import { isSameOrChild, normalizePath } from './utils/paths.ts'
148
147
  import { createLogger, type Logger } from './utils/logger.ts'
149
148
 
@@ -156,7 +155,7 @@ export const name = 'config-manager'
156
155
  export const inject = ['settings', 'credentials']
157
156
 
158
157
  /** Plugin version, kept in sync with package.json ("version"). */
159
- const PLUGIN_VERSION = '0.1.60'
158
+ const PLUGIN_VERSION = '0.1.62'
160
159
 
161
160
  /** Plugin own package name — excluded from its own exported plugins list. */
162
161
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -220,6 +219,8 @@ export interface Config {
220
219
 
221
220
  /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
222
221
  const API = {
222
+ // 设置页页脚版本行(pluginVersion / dshVersion)。只读,loopback fence。
223
+ status: '/api/dsh-config-manager/status',
223
224
  // P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
224
225
  // P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
225
226
  // m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
@@ -644,12 +645,12 @@ class DshWorkspaceFacade implements WorkspaceFacade {
644
645
  }
645
646
  }
646
647
 
647
- /** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
648
- const PROFILE_PATCH_FILE = 'cordis.patch.yml'
649
-
650
648
  /** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
651
- * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。 */
652
- class DshPatchFileFacade implements PatchFileFacade {
649
+ * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
650
+ *
651
+ * 导出仅为让测试能用**真实门面**(而非 mock)钉住层寻址——mock 会让两个层 token 的取值
652
+ * 撞车无处暴露(DshPluginsFacade 同款做法)。 */
653
+ export class DshPatchFileFacade implements PatchFileFacade {
653
654
  private readonly homeDir: string
654
655
  private readonly profile: string
655
656
  private readonly msg: MsgFunc
@@ -661,9 +662,9 @@ class DshPatchFileFacade implements PatchFileFacade {
661
662
  }
662
663
 
663
664
  private patchPath(file: string): string {
664
- if (file === USER_PATCH_FILE) return join(this.homeDir, USER_PATCH_FILE)
665
- if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, PROFILE_PATCH_FILE)
666
- throw new Error(this.msg('host.patchUnsupported', { user: USER_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
665
+ if (file === HOME_PATCH_FILE) return join(this.homeDir, HOME_PATCH_FILE)
666
+ if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, HOME_PATCH_FILE)
667
+ throw new Error(this.msg('host.patchUnsupported', { user: HOME_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
667
668
  }
668
669
 
669
670
  async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
@@ -916,48 +917,6 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string):
916
917
  }
917
918
  }
918
919
 
919
- /** Decrypt an encrypted backup's credentials (in-memory only; undefined when not applicable). */
920
- async function tryDecryptCredentials(
921
- zipPath: string,
922
- password: string | undefined,
923
- ): Promise<Map<string, string> | undefined> {
924
- if (password === undefined || password === '') return undefined
925
- const raw = await fs.readFile(zipPath)
926
- const archive = parseZip(raw)
927
- if (!archive.has(MANIFEST_FILE)) return undefined
928
- let manifest: Manifest
929
- try {
930
- manifest = parseManifest(archive.readEntryText(MANIFEST_FILE))
931
- } catch {
932
- return undefined
933
- }
934
- if (!manifest.security.encrypted || manifest.security.encryption === null) return undefined
935
- if (!archive.has('security/secrets.enc')) return undefined
936
- const blob = archive.readEntry('security/secrets.enc')
937
- const plaintext = await decryptCredentials(blob, manifest.security.encryption, password)
938
- let parsed: unknown
939
- try {
940
- parsed = yaml.load(plaintext)
941
- } catch {
942
- return undefined
943
- }
944
- const map = new Map<string, string>()
945
- if (parsed !== null && typeof parsed === 'object') {
946
- for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
947
- if (typeof v === 'string' && v !== '') map.set(k, v)
948
- }
949
- }
950
- return map
951
- }
952
-
953
- /** 解密错误 → 用户可读文本:BAD_PASSWORD 只报「密码错误」(不泄内部细节),其余原文 */
954
- function decryptErrorText(error: unknown, msg: MsgFunc): string {
955
- if (error instanceof SecurityError && error.code === 'BAD_PASSWORD') {
956
- return msg('import.encryptedPasswordWrong')
957
- }
958
- return error instanceof Error ? error.message : String(error)
959
- }
960
-
961
920
  interface RoutesDeps {
962
921
  host: ConfigManagerHostContext
963
922
  adapters: ConfigAdapter[]
@@ -1423,44 +1382,6 @@ export function isGitHubAuthMissing(error: unknown): boolean {
1423
1382
  return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token')
1424
1383
  }
1425
1384
 
1426
- /** /status 的插件诊断位(issue #28)。仅回非敏感元信息:目录、profile 名、计数。 */
1427
- export interface PluginDiagnostics {
1428
- homeDir: string
1429
- profile: string
1430
- /** profile 目录的 package.json 是否可读(不可读 → 清单必然为空) */
1431
- profileManifestReadable: boolean
1432
- /** 插件清单来源 = package.json 的 dependencies 里非 in-box 的包 */
1433
- installedPluginCount: number
1434
- installedPluginNames: string[]
1435
- /** dsh.profile.bundles 声明(非空即「替换默认插件栈」) */
1436
- bundles: string[]
1437
- }
1438
-
1439
- /**
1440
- * 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
1441
- * 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
1442
- * best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
1443
- */
1444
- async function readPluginDiagnostics(host: HostContext): Promise<Partial<PluginDiagnostics>> {
1445
- try {
1446
- const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web')
1447
- const manifest = readProfileManifest(profileDir)
1448
- const installed = await host.plugins.listInstalled()
1449
- const bundles = manifest?.dsh?.profile?.bundles
1450
- return {
1451
- homeDir: host.homeDir,
1452
- profile: host.profile ?? 'web',
1453
- profileManifestReadable: manifest !== null,
1454
- installedPluginCount: installed.length,
1455
- installedPluginNames: installed.map((p) => p.name),
1456
- bundles: Array.isArray(bundles) ? bundles : [],
1457
- }
1458
- } catch (err) {
1459
- host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`)
1460
- return {}
1461
- }
1462
- }
1463
-
1464
1385
  /** Build the /api/dsh-config-manager route family. */
1465
1386
  function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSyncScheduler; makeSyncEngine: (cfg: SyncConfig) => SyncEngine; lifecycle: ConfigLifecycle } {
1466
1387
  const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps
@@ -1856,6 +1777,18 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
1856
1777
 
1857
1778
  const routesList: WebRoute[] = [
1858
1779
  // ------------------------------------------------------------- status
1780
+ // 设置页页脚版本行:插件版本 + DSH 版本。只读、无 secret,loopback fence。
1781
+ {
1782
+ kind: 'exact',
1783
+ path: API.status,
1784
+ handler: async (req, res) => {
1785
+ if (!guard(req, res, 'GET')) return
1786
+ writeJson(res, 200, {
1787
+ pluginVersion: PLUGIN_VERSION,
1788
+ dshVersion: host.dshVersion,
1789
+ })
1790
+ },
1791
+ },
1859
1792
  // ------------------------------------------------------------- export
1860
1793
  // ---------------------------------------------------- export-preview
1861
1794
  // P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
@@ -1863,13 +1796,6 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
1863
1796
  // 零写入;loopback fence 必备。
1864
1797
  // ------------------------------------------------------------ download
1865
1798
  // -------------------------------------------------------------- upload
1866
- // ------------------------------------------------------ decrypt-archive
1867
- // 整体加密备份容器的解锁(只读,零写入到任何配置):用备份密码解密上传的加密容器,
1868
- // 得到明文 ZIP 写入受控临时目录并返回新 zipPath,供 analyze/plan/execute 引用。
1869
- // 导出时容器密码与内部 secrets.enc 密码同源(同一 password 派生两层加密),
1870
- // 因此顺带在明文 ZIP 上解出内部凭据覆盖清单(refs,非值)一并返回——
1871
- // 导入全程只需输入这一次密码,无需第二个密码校验页面。
1872
- // 密码仅内存随请求体传入,绝不落盘/落日志;解出的明文 ZIP 亦为临时文件,导入结束后清理。
1873
1799
  // ------------------------------------------------------------- analyze
1874
1800
  // ---------------------------------------------------------------- plan
1875
1801
  // ------------------------------------------------------------ progress
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * 安全模块公共出口(m4-security):
3
- * secret-scanner / encryption / integrity / zip-security / redaction。
3
+ * secret-scanner / integrity / zip-security / redaction。
4
4
  *
5
5
  * 与 core 的注入点对齐:
6
6
  * - `createSecretScanner()` → ExporterOptions.scanner(SecretScanner 契约)
7
- * - `createEncryptionProvider(pw)` → ExporterOptions.encryption(EncryptionProvider 契约)
8
7
  * - `createHardenedZipParser()` → ImporterOptions.parseZipOverride((buf, limits?) => ZipArchive)
9
8
  * - `safeExtractHardened()` → m5 导入文件类分区落盘通道
10
9
  * - `verifyChecksumsJson()` → 完整性校验通道(core analyzer 已有内置校验,本模块供独立调用)
11
10
  */
12
11
  export * from './secret-scanner.ts';
13
- export * from './encryption.ts';
14
12
  export * from './integrity.ts';
15
13
  export * from './zip-security.ts';
16
14
  export * from './redaction.ts';