Browse Source

fix(desktop): back up profile patches during recovery

Turtle 3 weeks ago
parent
commit
8d5c7e4e2a

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
-2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
-2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6
+2026-09-15-desktop-native-fatal-recovery.md: b42fdd07b33c52d3b09b735b7f70d14bc20c507b
+2026-09-15-desktop-native-fatal-recovery.zh.md: 9f42ba4213a5f9e473ca059b8ee2e2c2b3a1200c

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
 
 Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
 
-The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Web launchers can call the same function while owning their shutdown and write exclusion. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
 
 The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
 
@@ -22,6 +22,8 @@ This supersedes recovery-page and reset behavior in the [immediate-window decisi
 
 A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
 
+**Disable bundles while retaining the active profile patch.** A malformed patch or a patch that inserts a broken plugin can still prevent startup. Renaming preserves the user’s exact bytes for manual repair while removing that layer from startup; unique backup names preserve earlier recovery attempts.
+
 ## Consequences
 
 Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
 
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。Web 启动器可在负责关闭和排除并发写入的前提下调用同一函数。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
 
 Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
 
@@ -22,6 +22,8 @@ Web 文档保留在原位。宿主回调负责启动失败展示,共享启动
 
 Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
 
+**禁用 bundle,但保留生效的 profile patch。** 损坏的 patch 或插入故障插件的 patch 仍可阻止启动。重命名保留用户原始内容供手动修复,同时将该层移出启动过程;唯一备份名保留此前恢复操作的备份。
+
 ## Consequences
 
 恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: d19304ae123aefe57af649c5d6a02c4db867314e
-README.zh.md: 3d5b84c23701c4915fb1983fd6363c1ce2dc3059
+README.md: 579cabb664de3ff2636d23ff62ba965af6f6c4f4
+README.zh.md: 81e7927e43abff617493294aa4adb62945edf1c7

+ 2 - 2
apps/desktop/README.md

@@ -62,11 +62,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up configuration, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
 
 Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
+Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<uuid>` without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
 
 Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -62,11 +62,11 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份配置并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
 
 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
+恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<uuid>`,无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
 
 包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 

+ 2 - 2
apps/desktop/src/locale.ts

@@ -9,7 +9,7 @@ export const en = {
   exitApplication: 'Exit',
   restartApplication: 'Restart',
   recoveryOperationFailed: 'The recovery operation failed',
-  disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
+  disableThirdPartyPlugins: 'Disable third-party plugins, back up configuration, and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -59,7 +59,7 @@ export const zh = {
   exitApplication: '退出',
   restartApplication: '重启',
   recoveryOperationFailed: '恢复操作失败',
-  disableThirdPartyPlugins: '禁用全部第三方插件并重启',
+  disableThirdPartyPlugins: '禁用第三方插件、备份配置并重启',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',

+ 3 - 4
apps/desktop/src/project-manager.ts

@@ -25,7 +25,7 @@ import type { DesktopPaths } from './paths.ts'
 import type { DesktopRelease } from './release.ts'
 import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
-  initProfile, PROFILE_TEMPLATES, readProfileManifest, readProfilePlugins, reconcileProfilePlugins,
+  initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
   unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
 } from '@deepseek-ai/dsh-app-boot'
 import { migrateDesktopProfileLinks } from './profile-packages.ts'
@@ -122,13 +122,12 @@ export class DesktopProjectManager {
   }
 
   /**
-   * Disable third-party bundles without loading application resources or deleting plugin files.
+   * Back up the profile patch and disable third-party bundles without loading application resources.
    * @returns Completion of the locked profile write; the caller must stop the Host first.
    */
   async disableAllPlugins(): Promise<void> {
     await this.withLock(() => {
-      if (!existsSync(join(this.paths.profile, 'package.json'))) return
-      writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
+      sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles)
     })
   }
 

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-en.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
 Exit
 Restart
-Disable all third-party plugins and restart
+Disable third-party plugins, back up configuration, and restart

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-zh-CN.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
 退出
 重启
-禁用全部第三方插件并重启
+禁用第三方插件、备份配置并重启

+ 1 - 1
apps/desktop/tests/main-startup.spec.ts

@@ -392,7 +392,7 @@ describe('desktop main startup', () => {
     harness.prepared.reject(new Error('runtime resources missing'))
     await harness.dialogShown.promise
     expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
-    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up configuration, and restart'])
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 

+ 8 - 3
apps/desktop/tests/project-manager.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -109,7 +109,7 @@ describe('desktop external plugin profile', () => {
     await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
-  it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
+  it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
     const { manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
@@ -117,7 +117,10 @@ describe('desktop external plugin profile', () => {
     writeFileSync(patch, ': broken')
     const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
     await uninitialized.disableAllPlugins()
-    expect(readFileSync(patch, 'utf8')).toBe(': broken')
+    expect(existsSync(patch)).toBe(false)
+    const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
+    expect(backups).toHaveLength(1)
+    expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
     expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
     const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
       dependencies: Record<string, string>
@@ -126,6 +129,8 @@ describe('desktop external plugin profile', () => {
     expect(manifest.dependencies.plugin).toBe('1.0.0')
     expect(manifest.dsh.profile.bundles).not.toContain('plugin')
     expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
+    await manager.applyRelease()
+    expect(readFileSync(patch, 'utf8')).toContain('[]')
   })
 
   it('needs no runtime or package manifest when no plugins have been installed', async () => {

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: cf0d67fb14633cfdf9a30754125b3e08df2d0496
-README.zh.md: dab003c581ba5744893e612eeee812b56c25f1a2
+README.md: ba1323fb82333eb831679dfec614b8abff0781db
+README.zh.md: d1275e3e619bde4ff0af768bd81e869deeb77679

+ 2 - 0
packages/boot/app-boot/README.md

@@ -60,6 +60,8 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. The default link mode materializes the existing shared and profile-owned fallback links, so supported launch behavior stays unchanged. Internal callers and test harnesses can instead install the generation through Node's ESM and CommonJS resolvers in runtime mode, or materialize and verify the same generation in dual mode.
 
+`sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery for Web launchers and Desktop without loading plugins or parsing patches. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<uuid>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
+
 ### Previewing the effective configuration
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.

+ 2 - 0
packages/boot/app-boot/README.zh.md

@@ -60,6 +60,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。默认 link 模式会物化现有的共享 fallback 链接与 profile 自有 fallback 链接,因此受支持的启动行为保持不变。内部调用方和测试工具可以改用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以使用 dual 模式,同时物化并校验同一份 generation。
 
+`sanitizeProfile(binName, profileDir, bundles)` 为 Web 启动器和 Desktop 提供文件恢复,无需加载插件或解析 patch。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<uuid>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
+
 ### 预览生效配置
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。

+ 1 - 0
packages/boot/app-boot/src/index.ts

@@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
 import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 export { readProfilePatches, resolveTelemetryPatch, type ProfileContext } from './profile-context.ts'
+export { sanitizeProfile } from './profile-sanitize.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 export {

+ 31 - 0
packages/boot/app-boot/src/profile-sanitize.ts

@@ -0,0 +1,31 @@
+/** Profile recovery shared by Web launchers and the Desktop shell. */
+
+import { randomUUID } from 'node:crypto'
+import { existsSync, renameSync } from 'node:fs'
+import { join } from 'node:path'
+import { PROFILE_PATCH_FILENAME, readProfileManifest } from './profile.ts'
+import { writeProfileBundles } from './profile-plugins.ts'
+
+/**
+ * Back up the profile patch and retain only the caller's recovery bundles.
+ * The caller must stop the profile and exclude concurrent profile writes.
+ * Installed packages and other manifest fields are preserved; patches are never parsed.
+ * Failures propagate and may leave completed changes in place for a retry.
+ * @param binName - Diagnostic prefix for invalid profile manifests.
+ * @param profileDir - Profile directory to recover without loading its plugins.
+ * @param bundles - Ordered bundles to enable after recovery.
+ * @returns Renamed patch path with a unique `.bak-<uuid>` suffix, or undefined if absent.
+ */
+export function sanitizeProfile(binName: string, profileDir: string, bundles: readonly string[]): string | undefined {
+  const manifest = existsSync(join(profileDir, 'package.json')) ? readProfileManifest(binName, profileDir) : undefined
+  const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
+  let backupPath: string | undefined = `${patchPath}.bak-${randomUUID()}`
+  try {
+    renameSync(patchPath, backupPath)
+  } catch (error) {
+    if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
+    backupPath = undefined
+  }
+  if (manifest !== undefined) writeProfileBundles(profileDir, manifest, bundles)
+  return backupPath
+}

+ 88 - 0
packages/boot/app-boot/tests/profile-sanitize.spec.ts

@@ -0,0 +1,88 @@
+/** Recovery preserves user files while removing them from profile startup. */
+
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it, vi } from 'vitest'
+import { initProfile, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, sanitizeProfile } from '../src/index.ts'
+
+vi.mock('node:fs', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs')>()
+  return { ...actual, renameSync: vi.fn(actual.renameSync) }
+})
+
+const roots: string[] = []
+afterEach(() => {
+  vi.mocked(renameSync).mockClear()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture(name = 'web') {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-profile-sanitize-'))
+  roots.push(root)
+  const dir = join(root, 'profiles', name)
+  const bundles = PROFILE_TEMPLATES.web!.bundles
+  initProfile(dir, [...bundles, 'broken-plugin'])
+  const manifestPath = join(dir, 'package.json')
+  const manifest = { ...readProfileManifest('test', dir), dependencies: { 'broken-plugin': '1.2.3' }, custom: 'retained' }
+  writeFileSync(manifestPath, JSON.stringify(manifest))
+  const patch = join(dir, PROFILE_PATCH_FILENAME)
+  writeFileSync(patch, ': broken YAML')
+  return { root, dir, bundles, manifest, manifestPath, patch }
+}
+
+it.each(['web', 'desktop'])('recovers the %s profile without loading its broken plugins or patch', (name) => {
+  const { dir, bundles, manifest, patch } = fixture(name)
+  const packageDir = join(dir, 'node_modules', 'broken-plugin')
+  mkdirSync(packageDir, { recursive: true })
+  const pluginManifest = join(packageDir, 'package.json')
+  writeFileSync(pluginManifest, '{broken')
+  const backup = sanitizeProfile('test', dir, bundles)
+  expect(backup).toMatch(/cordis\.patch\.yml\.bak-[\da-f-]+$/u)
+  expect(existsSync(patch)).toBe(false)
+  expect(readFileSync(backup!, 'utf8')).toBe(': broken YAML')
+  expect(readFileSync(pluginManifest, 'utf8')).toBe('{broken')
+  expect(readProfileManifest('test', dir)).toEqual({ ...manifest, dsh: { profile: { bundles } } })
+  initProfile(dir, bundles)
+  expect(readFileSync(patch, 'utf8')).toContain('[]')
+})
+
+it('preserves previous backups across retries and later recovery actions', () => {
+  const { dir, bundles, patch } = fixture()
+  const first = sanitizeProfile('test', dir, bundles)!
+  expect(sanitizeProfile('test', dir, bundles)).toBeUndefined()
+  writeFileSync(patch, 'second patch')
+  const second = sanitizeProfile('test', dir, bundles)!
+  expect(second).not.toBe(first)
+  expect(readFileSync(first, 'utf8')).toBe(': broken YAML')
+  expect(readFileSync(second, 'utf8')).toBe('second patch')
+})
+
+it('does not create an absent profile and backs up a patch even without a manifest', () => {
+  const { root, bundles } = fixture()
+  const dir = join(root, 'missing')
+  expect(sanitizeProfile('test', dir, bundles)).toBeUndefined()
+  expect(existsSync(dir)).toBe(false)
+  mkdirSync(dir)
+  writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'orphan patch')
+  const backup = sanitizeProfile('test', dir, bundles)!
+  expect(readFileSync(backup, 'utf8')).toBe('orphan patch')
+  expect(existsSync(join(dir, 'package.json'))).toBe(false)
+})
+
+it('leaves the patch untouched when profile JSON is invalid', () => {
+  const { dir, bundles, manifestPath, patch } = fixture()
+  writeFileSync(manifestPath, '{broken')
+  expect(() => sanitizeProfile('test', dir, bundles)).toThrow()
+  expect(readFileSync(patch, 'utf8')).toBe(': broken YAML')
+  expect(readdirSync(dir).some(name => name.includes('.bak-'))).toBe(false)
+})
+
+it('propagates backup failures before changing activation', () => {
+  const { dir, bundles, manifest, patch } = fixture()
+  const error = Object.assign(new Error('patch rename denied'), { code: 'EACCES' })
+  vi.mocked(renameSync).mockImplementationOnce(() => { throw error })
+  expect(() => sanitizeProfile('test', dir, bundles)).toThrow(error)
+  expect(readFileSync(patch, 'utf8')).toBe(': broken YAML')
+  expect(readProfileManifest('test', dir)).toEqual(manifest)
+})