@gehennawu/dsh-service 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -9,9 +9,9 @@
9
9
  <em>DeepSeek Harness (DSH) Web 服务控制与运维插件。</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-1.9.4-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-1.9.6-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.1.6--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.1.7--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -73,7 +73,8 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
73
73
  ### Version and updates
74
74
 
75
75
  - Shows the current DSH and plugin versions, linking to GitHub Releases
76
- - Automatically checks npm **stable + preview** (latest / next dist-tags); when a new version exists, an inline expandable compares them, each with npmjs and npmmirror links
76
+ - Automatically checks npm **stable + preview** (latest / next dist-tags)
77
+ - "What's new" sits right after the current version number and is always available: it opens the notes for **the version you are running**; when a new version exists, the status text ("New version: x.y.z") is clickable and expands **the new version's** release info. Either way the host fetches the body from the GitHub Releases API and the client renders it in place (plain Markdown — no iframe, no navigation), with the version number, publish date and a pre-release tag; a missing Release or a failed read each get their own message; clicking anywhere outside the version card or pressing Escape closes it
77
78
  - One-click upgrade with automatic restart; when no process manager is detected, it confirms the consequences first, keeps running, and shows manual-restart instructions
78
79
  - Between the upgrade landing and the process restart (common in a manual-launch environment) the version row reads "Installed X — restart to take effect" and the upgrade button is withdrawn; reopening the panel or refreshing the page keeps that state until the process is restarted
79
80
 
@@ -227,6 +228,7 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
227
228
  - **Unrecognised names fall back to the quota manual adaptation**: when a channel matches neither the built-in table nor any prefix rule, but you have **manually adapted** it in the quota page to some kind (`cpa` → CLIProxyAPI, or any relay → OpenRouter / Zhipu / Command Code / StepFun…), its brand mark is shown; changing the adapted kind swaps the icon immediately, and un-adapting falls back to the official default icon. Two premises: only **manual** adaptation counts (a kind merely auto-inferred from the baseURL does not), and **the channel name always wins** when it matches (`openrouter-f` keeps showing OpenRouter even if you adapt it to something else)
228
229
  - **The CLIProxyAPI icon appears on demand**: the hand-drawn "concave diamond + mirrored swirl" mark shows on the model button **only after you have manually adapted some provider as CLIProxyAPI in the quota page** (regardless of the provider's name — `cpa` or a custom one alike); it is one instance of the fallback rule above, and un-adapting immediately falls back to the official default icon
229
230
  - **Quota cards carry the icons too**: adapted provider cards show the same brand mark before the channel name (14px; colour tiers keep their brand colour, mono tiers follow the theme text colour). The same "fall back to the manual adaptation" rule applies here — the card list is exactly where you adapt, so the mark appears/swaps immediately
231
+ - **The model picker's group headings carry them too**: open the model button → the "Model" list, and each **provider/channel group heading gets the same mark in front of it** (13px, matching the 12px heading scale). Same resolution and fallback rules: unrecognised groups keep the official heading untouched, with no icon added
230
232
  - **Legible in both light and dark themes**: a brand colour is kept only when its contrast is adequate against **both** a light and a dark background; otherwise the icon automatically switches to a monochrome variant that follows the theme's text colour — so you never get a black logo on a dark background, or a nearly invisible one in light mode
231
233
  - **Zero runtime network requests**: icons are inlined into the client bundle at build time, so it works offline, needs no CSP exceptions, and never leaks your provider names to a third party
232
234
  - The icon is sized to match the **quota ring** beside the composer (each mark's viewBox is tightened to its real drawn extent and squared at build time, so every brand reads at the same visual size instead of some filling the box and others shrinking)
@@ -351,7 +353,7 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
351
353
 
352
354
  Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
353
355
 
354
- **DSH compatibility statement**: adapted to DSH `0.1.6-alpha.2` — session format V3 (`system/message` events in history, renamed PTC vocabulary; the detail view automatically archives system events), the handle-based sessionPersistence (usage refresh, title cache, and diagnostics counts all ride the new public surface `list`/`open`/`read`/`close`), the official right sidebar replacing the Detail column (the mobile right-edge gesture drives `ctx.layout.openRightbar/closeRightbar` directly), the object-shaped official turn-process (subagent turn claiming supports both shapes), dual-hash compatibility for mobile bottom-row triggers, subagent turn-tail list-slot adaptive compatibility, `plugins.bundle.config` slot injection for the new Plugins page, and session-detail open fallback through `uiWorkspace`. Older DSH releases (`>=0.1.1-rc.2`) remain supported: persistence and layout seams run in dual shapes detected from runtime capabilities, and adaptation items that target newer structures are naturally inert on older hosts (cosmetic only, no functional loss). Note: sessions written in the V3 format after upgrading cannot be read by older DSH releases — **backups do not restore across a version downgrade**. The version card shows a standing notice "Adapted for DSH 0.1.1-rc.2 ~ 0.1.6-alpha.2", and turns red when running on unverified releases (`≥0.1.6-alpha.3`).
356
+ **DSH compatibility statement**: adapted to DSH `0.1.7-alpha.1` — session format V4 (V3 logs are migrated by the official layer into a `session.v4.jsonl.zstd` generation file on first read, with the old `session.v3.jsonl.zstd` retained per the format catalog's policy; the detail view automatically archives system events), the `SettingsForms` configuration surface (plugin config now lives in the Profile's `cordis.patch.yml` with hot updates over `loader/volatile-update`, while the legacy `settings.register` remains authoritative on 0.1.5/0.1.6 so the two never cross-contaminate on a shared host), and all existing V3 adaptation (the `system/message` history, the handle-based sessionPersistence, the official right sidebar, the object-shaped turn-process), plus dual-hash compatibility for mobile bottom-row triggers, subagent turn-tail list-slot adaptive compatibility, `plugins.bundle.config` slot injection, and session-detail open fallback through `uiWorkspace`. Older DSH releases (`>=0.1.1-rc.2`) remain supported: the settings surface and the persistence/layout seams all run in dual shapes detected from runtime capabilities, and adaptation items that target newer structures are naturally inert on older hosts (cosmetic only, no functional loss). Note: sessions written after upgrading cannot be read by older DSH releases — **backups do not restore across a version downgrade**. The plugin marketplace judges compatibility from the `engines.dsh` range in `package.json`, which is the single declaration of the supported range.
355
357
 
356
358
  ## 🔒 Security design
357
359
 
package/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  <em>A service-control &amp; operations plugin for DeepSeek Harness (DSH) Web.</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-1.9.4-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-1.9.6-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.1.6--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.1.7--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -73,7 +73,8 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
73
73
  ### 版本与更新
74
74
 
75
75
  - 显示当前 DSH 与插件版本,链接 GitHub Releases
76
- - 自动检查 npm **正式版 + 预览版**(latest / next 双 tag);有新版本时行内展开对比,版本号附 npmjs 与 npmmirror 双链接
76
+ - 自动检查 npm **正式版 + 预览版**(latest / next 双 tag)
77
+ - 「本次更新内容」入口跟在当前版本号之后且常驻:点开读**当前这一版**的更新说明;有新版本时状态文本「有新版本:x.y.z」整体可点,展开**新版**的 release 信息。正文一律由宿主从 GitHub Releases API 取回后就地渲染(纯 Markdown,不嵌 iframe、不跳转),带版本号、发布日期与预发布标记;未建 Release 或读取失败各有明确提示;点版本卡以外的任意位置或按 Esc 即关闭
77
78
  - 一键升级,完成后自动重启;未检测到进程管理器时先确认后果,保持运行并提示手动重启
78
79
  - 升级落地但进程尚未重启期间(手动启动环境尤为常见),版本行改示「已安装 X,重启后生效」并收起升级按钮,重开面板或刷新页面状态依旧;重启进程后恢复常态
79
80
 
@@ -229,6 +230,7 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
229
230
  - **识别不到时按余额查询的手动适配兜底**:某个渠道名既不在内置表、也不符合前缀规则时,如果你在余额查询里把它**手动适配**成了某个类型(如 `cpa` → CLIProxyAPI,或任意中转 → OpenRouter / 智谱 / Command Code / StepFun 等),就显示该类型的厂家图标;适配类型一改图标立即跟着换,撤销适配立即回落官方默认图标。两个前提:只有**手动**适配算数(仅由 baseURL 自动推断出来的类型不算),且**名字能识别时以名字为准**(叫 `openrouter-f` 的渠道即使另外适配成别的类型,也仍显示 OpenRouter 标)
230
231
  - **CLIProxyAPI 图标按需出现**:手绘的「内凹菱形 + 镜像漩涡」品牌标**只在你在余额查询里把某个渠道手动适配成 CLIProxyAPI 后**才显示在模型按钮上(渠道名不限,叫 `cpa` 还是自定义名都一样);它就是上面那条兜底规则的一个实例,撤销适配立即回落官方默认图标
231
232
  - **余额查询的渠道卡片同样带图标**:已适配卡片的渠道名前显示同一套厂家小图标(14px;彩色档原样上品牌色、单色档跟随主题文字色)。上面那条「按手动适配兜底」的规则在这里同样生效——卡片区正是你做适配的地方,改完适配立即出现/换标
233
+ - **模型选择弹窗的分组标题同样带图标**:点开模型按钮 → 「模型」二级列表后,每个**厂家/渠道商分组名前面**也显示同一枚图标(13px,与 12px 的分组标题同量级)。同一套解析与兜底规则,未识别的渠道分组保持官方标题原样、不加图标
232
234
  - **浅色 / 深色都清晰**:品牌色对浅底与深底**两侧**对比度都达标才保留原色,否则自动改用跟随主题文字色的单色版——不会出现深色模式下「黑图标糊在黑底上」或浅色模式下近乎隐形
233
235
  - **零运行期网络请求**:图标在构建期内联进客户端产物,离线可用、不影响 CSP,也不向第三方暴露你的渠道名称
234
236
  - 图标尺寸与输入框旁的**额度圆环**一致(生成期把每个图标的 viewBox 收紧到真实绘制范围并正方形化,所以各品牌「看起来一样大」,不会有的满格有的缩成一团)
@@ -353,7 +355,7 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
353
355
 
354
356
  运行要求:Node.js `>=22`,DSH Web 能加载 Host 与 Client 两半插件。更新检查需访问 `registry.npmjs.org`;网络失败不影响其他功能。
355
357
 
356
- **DSH 适配口径**:已适配 DSH `0.1.6-alpha.2`——会话格式 V3(`system/message` 入史、旧 PTC 词汇更名,详情视图自动归档系统事件)、sessionPersistence handle 化(用量增量、标题缓存、诊断计数全部按新公共面 `list`/`open`/`read`/`close` 走)、官方右栏替代详情列(移动端右缘手势直接驱动 `ctx.layout.openRightbar/closeRightbar`)、官方 turn-process 对象化(子代理回合认领双形态兼容)、移动端底行触发钮双哈希兼容、子代理回合尾模型行 list 槽位自适应兼容、新插件管理页 `plugins.bundle.config` 槽位注入、会话详情打开接入 `uiWorkspace` 降级链路。旧版 DSH(`>=0.1.1-rc.2`)保持兼容:新旧两套 persistence/布局 seam 按运行时能力探测双形态走,旧宿主上针对新结构的适配项天然不生效(纯展示,无功能损失)。注意:升级后以 V3 格式写入的会话日志无法被旧版 DSH 读取——**备份不可跨版本降级恢复**。版本卡常驻显示「适配 DSH 0.1.1-rc.2 ~ 0.1.6-alpha.2」,越界运行版本(`≥0.1.6-alpha.3`)标红警示。
358
+ **DSH 适配口径**:已适配 DSH `0.1.7-alpha.1`——会话格式 V4(V3 日志首读时由官方迁移为 `session.v4.jsonl.zstd` 代际文件,旧 `session.v3.jsonl.zstd` 按格式目录策略保留;详情视图自动归档系统事件)、`SettingsForms` 配置面(插件配置改由 Profile 的 `cordis.patch.yml` 承载,热更新走 `loader/volatile-update`;旧的 `settings.register` 在 0.1.5/0.1.6 上仍是权威来源,装在同一宿主上两者互不串台)、会话格式 V3 既有适配全部保留(`system/message` 入史、sessionPersistence handle 化、官方右栏、turn-process 对象化)、移动端底行触发钮双哈希兼容、子代理回合尾模型行 list 槽位自适应兼容、`plugins.bundle.config` 槽位注入、会话详情打开接入 `uiWorkspace` 降级链路。旧版 DSH(`>=0.1.1-rc.2`)保持兼容:设置面、persistence/布局 seam 均按运行时能力探测走双形态,旧宿主上针对新结构的适配项天然不生效(纯展示,无功能损失)。注意:升级后写入的会话日志无法被旧版 DSH 读取,**备份不可跨版本降级恢复**。插件市场按 `package.json` 的 `engines.dsh` 区间判定兼容性(该字段是唯一的支持口径声明)。
357
359
 
358
360
  ## 🔒 安全设计
359
361
 
@@ -6,6 +6,10 @@ import { promisify } from 'node:util'
6
6
  import { gunzip } from 'node:zlib'
7
7
 
8
8
  const CONFIG_FILES = Object.freeze(['settings.yaml', 'cordis.patch.yml', 'AGENTS.md', 'dsh-service-config.json'])
9
+ // 每个 profile 允许打包/恢复的常规文件白名单(精确匹配文件名,不许通配或子目录)。
10
+ // `cordis.patch.yml` 是 0.1.7-alpha.1 起的 Profile 用户配置层:漏掉它会出现「备份成功、
11
+ // 恢复后个性化配置全丢」。package.json 承载 bundle 启停清单,两者必须成对。
12
+ const PROFILE_FILES = Object.freeze(['package.json', 'cordis.patch.yml'])
9
13
  const PLAN_TTL_MS = 5 * 60 * 1000
10
14
  const MAX_COMPRESSED_BYTES = 512 * 1024 * 1024
11
15
  const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
@@ -90,7 +94,7 @@ function emptySections() {
90
94
  return {
91
95
  sessions: { files: 0, dirs: 0, bytes: 0 },
92
96
  config: { files: [], missing: [...CONFIG_FILES], bytes: 0 },
93
- profiles: { items: [], count: 0, bytes: 0 },
97
+ profiles: { items: [], count: 0, bytes: 0, patchFiles: [] },
94
98
  }
95
99
  }
96
100
 
@@ -122,11 +126,18 @@ function validateEntry(path, type, data, state) {
122
126
  if (parts.length === 1) return
123
127
  if (parts[1] === '.' || parts[1] === '..') throw domainError('backup-entry-traversal')
124
128
  if (parts.length === 2 && type === 'directory') return
125
- if (parts.length !== 3 || parts[2] !== 'package.json' || type !== 'file') throw domainError('backup-entry-unexpected')
126
- let manifest
127
- try { manifest = JSON.parse(data.toString('utf8')) } catch (_) { throw domainError('backup-profile-invalid') }
128
- if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) throw domainError('backup-profile-invalid')
129
- sections.profiles.items.push({ name: parts[1], sizeBytes: data.length })
129
+ if (parts.length !== 3 || type !== 'file' || !PROFILE_FILES.includes(parts[2])) throw domainError('backup-entry-unexpected')
130
+ if (parts[2] === 'package.json') {
131
+ let manifest
132
+ try { manifest = JSON.parse(data.toString('utf8')) } catch (_) { throw domainError('backup-profile-invalid') }
133
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) throw domainError('backup-profile-invalid')
134
+ sections.profiles.items.push({ name: parts[1], sizeBytes: data.length })
135
+ sections.profiles.bytes += data.length
136
+ return
137
+ }
138
+ // Profile 补丁:内容是用户手写的 YAML patch 层,此处只记清单与字节,不做语义校验
139
+ // (解析失败应仍可恢复——恢复的是「用户当时的文件」,不是「合法的 patch」)。
140
+ sections.profiles.patchFiles.push({ name: parts[1], sizeBytes: data.length })
130
141
  sections.profiles.bytes += data.length
131
142
  }
132
143
 
@@ -213,8 +224,12 @@ function parseTar(expanded, options = {}) {
213
224
  for (const root of ['sessions', 'config', 'profiles']) if (!state.present.has(root)) throw domainError('backup-section-missing', root)
214
225
  state.sections.config.files.sort((a, b) => a.name.localeCompare(b.name))
215
226
  state.sections.profiles.items.sort((a, b) => a.name.localeCompare(b.name))
227
+ state.sections.profiles.patchFiles.sort((a, b) => a.name.localeCompare(b.name))
216
228
  state.sections.profiles.count = state.sections.profiles.items.length
217
- return { entries, sections: state.sections, logicalBytes, entryCount: entries.length }
229
+ // 归档格式标签:v1 = 不含 Profile 补丁(0.1.7-alpha.1 之前的插件所出);v2 = 含补丁层。
230
+ // 恢复旧归档时据此给出「未包含项」提示而不判损坏。
231
+ const archiveFormat = state.sections.profiles.patchFiles.length > 0 ? 'v2' : 'v1'
232
+ return { entries, sections: state.sections, logicalBytes, entryCount: entries.length, archiveFormat }
218
233
  }
219
234
 
220
235
  async function inspectArchive(source, options = {}) {
@@ -226,6 +241,7 @@ async function inspectArchive(source, options = {}) {
226
241
  validForRestore: false,
227
242
  status: 'error',
228
243
  archive: { entryCount: 0, compressedBytes: source.sizeBytes, logicalBytes: 0 },
244
+ archiveFormat: 'v1',
229
245
  sections: emptySections(),
230
246
  issues: [],
231
247
  issueCount: 0,
@@ -254,6 +270,7 @@ async function inspectArchive(source, options = {}) {
254
270
  validForRestore: true,
255
271
  status: 'ok',
256
272
  archive: { entryCount: parsed.entryCount, compressedBytes: compressed.length, logicalBytes: parsed.logicalBytes },
273
+ archiveFormat: parsed.archiveFormat,
257
274
  sections: parsed.sections,
258
275
  },
259
276
  parsed,
@@ -365,7 +382,9 @@ async function fingerprintTargets(dshHome, profileNames) {
365
382
  for (const name of profileNames) {
366
383
  const profileRoot = join(profilesRoot, name)
367
384
  await assertDirectoryOrMissing(profileRoot)
368
- await fingerprintNode(join(profileRoot, 'package.json'), `profiles/${name}/package.json`, hash, summary)
385
+ // 目标指纹覆盖白名单里的每个文件:漏掉补丁会让「恢复期间用户改了配置」检测不到
386
+ // (指纹相同 → 覆盖写,用户的新改动被旧快照悄悄顶掉)。
387
+ for (const file of PROFILE_FILES) await fingerprintNode(join(profileRoot, file), `profiles/${name}/${file}`, hash, summary)
369
388
  }
370
389
  return { fingerprint: hash.digest('hex'), bytes: summary.bytes }
371
390
  }
@@ -510,7 +529,13 @@ export function createBackupIntegrity(options) {
510
529
  if (source === undefined) throw domainError('unknown-backup')
511
530
  const inspected = await inspectArchive(source, { collectEntries: true })
512
531
  if (!inspected.report.validForRestore || inspected.parsed === null) throw domainError('backup-archive-invalid')
513
- const profileNames = inspected.report.sections.profiles.items.map((item) => item.name)
532
+ // 归档可能带补丁而不带 manifest(外部工具所出):指纹与恢复操作都按并集处理,
533
+ // 否则「只改了补丁的 profile」在 target 变更检测里是盲区。
534
+ const profileNames = [...new Set([
535
+ ...inspected.report.sections.profiles.items.map((item) => item.name),
536
+ ...inspected.report.sections.profiles.patchFiles.map((item) => item.name),
537
+ ])].sort()
538
+ const patchProfileNames = inspected.report.sections.profiles.patchFiles.map((item) => item.name)
514
539
  const targetState = await fingerprintTargets(dshHome, profileNames)
515
540
  const planId = randomUUID()
516
541
  const staging = join(dshHome, 'backups', `.restore-plan-${planId}`)
@@ -530,19 +555,23 @@ export function createBackupIntegrity(options) {
530
555
  sourceFingerprint: inspected.report.source.sha256,
531
556
  targetFingerprint: targetState.fingerprint,
532
557
  profileNames,
558
+ patchProfileNames,
533
559
  reportSummary: {
534
560
  entryCount: inspected.report.archive.entryCount,
535
561
  logicalBytes: inspected.report.archive.logicalBytes,
536
562
  sessions: inspected.report.sections.sessions,
537
563
  configFiles: inspected.report.sections.config.files.length,
538
564
  profiles: profileNames.length,
565
+ profilePatches: patchProfileNames.length,
566
+ archiveFormat: inspected.report.archiveFormat,
539
567
  },
540
568
  targets: {
541
569
  sessions: { action: 'replace', currentBytes: targetState.bytes, newBytes: inspected.report.sections.sessions.bytes },
542
570
  config: { replace: inspected.report.sections.config.files.map((item) => item.name), remove: configRemove, newBytes: inspected.report.sections.config.bytes },
543
- profiles: { upsert: profileNames, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
571
+ profiles: { upsert: profileNames, patches: patchProfileNames, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
544
572
  },
545
- consequences: ['sessions-replaced', ...(configRemove.length > 0 ? ['config-files-removed'] : []), ...(profileNames.length > 0 ? ['profile-manifests-replaced'] : []), 'service-restart-required'],
573
+ // 旧归档(v1,不含补丁层)恢复时显式告知「哪些文件没带」,不判损坏、不阻断。
574
+ consequences: ['sessions-replaced', ...(configRemove.length > 0 ? ['config-files-removed'] : []), ...(profileNames.length > 0 ? ['profile-manifests-replaced'] : []), ...(patchProfileNames.length > 0 ? ['profile-patches-replaced'] : ['profile-patches-absent']), 'service-restart-required'],
546
575
  previousInstanceId,
547
576
  runtime: { supervisorKind: runtimeEnv?.supervisorKind ?? null, manualStartLikely: runtimeEnv?.manualStartLikely === true },
548
577
  }
@@ -588,7 +617,17 @@ export function createBackupIntegrity(options) {
588
617
  }
589
618
  await addOperation(join(dshHome, 'sessions'), join(plan.staging, 'sessions'), 'sessions')
590
619
  for (const name of CONFIG_FILES) await addOperation(join(dshHome, name), join(plan.staging, 'config', name), `config/${name}`)
591
- for (const name of plan.profileNames) await addOperation(join(dshHome, 'profiles', name, 'package.json'), join(plan.staging, 'profiles', name, 'package.json'), `profiles/${name}/package.json`)
620
+ for (const name of plan.profileNames) {
621
+ await addOperation(join(dshHome, 'profiles', name, 'package.json'), join(plan.staging, 'profiles', name, 'package.json'), `profiles/${name}/package.json`)
622
+ // 补丁层白名单恢复。归档没带(旧版 v1 所出)时**不登记**这一步:登记了就代表
623
+ // 「按快照覆盖」,operation 循环会先把目标侧 patch 挪走、再因 staged 缺席而不回填,
624
+ // 等于用一份对配置毫无意见的旧归档静默删掉用户当前配置。缺席只记入计划报告
625
+ // (consequences: profile-patches-absent),不改成删除。
626
+ const stagedPatch = join(plan.staging, 'profiles', name, 'cordis.patch.yml')
627
+ if (await pathExists(stagedPatch)) {
628
+ await addOperation(join(dshHome, 'profiles', name, 'cordis.patch.yml'), stagedPatch, `profiles/${name}/cordis.patch.yml`)
629
+ }
630
+ }
592
631
  const journalPath = join(dshHome, JOURNAL_FILE)
593
632
  await mkdir(rollbackDir, { recursive: true, mode: 0o700 })
594
633
  await writeJournal(journalPath, { version: 1, rollbackDir, operations })