Sfoglia il codice sorgente

fix(desktop): clean profile core packages before production startup

07akioni 4 giorni fa
parent
commit
09be5f750a

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.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-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: 18158cff7478624f2dffbfbccd987c6a3dccfe9d
-2026-09-09-desktop-in-place-profile.zh.md: 3f20f4ef2594db0714ad200fb590c94fdd4a784e
+2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
+2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md

@@ -10,6 +10,8 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
+Application-owned package retention is qualified by the [production cleanup decision](../bug-fix/2026-09-15-desktop-profile-core-cleanup.md).
+
 Desktop stops the Host and modifies the current profile directly. Shared app-boot cleanup detaches its own fallback links before package changes; the Host’s shared profile runner supplies required links on startup. Package locking and configured lifecycle scripts remain. Upgrades refresh module links without copying plugin files.
 
 Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md

@@ -10,6 +10,8 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
+应用管理包的保留范围受[生产清理决策](../bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md)限定。
+
 Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
+2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
+2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md

@@ -0,0 +1,27 @@
+# Agent Note: Clean application-owned packages before Desktop production boot
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-profile-core-cleanup.zh.md)
+
+## Problem
+
+Old Desktop profiles contain installed core packages and local tarball dependency declarations. Local package precedence can combine an old Web frontend with new plugins even when the application carries a consistent release. Development fallback links also remain when users switch to an installed application.
+
+## Decision
+
+Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
+
+The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
+
+## Alternatives considered
+
+**Installer-only cleanup** misses other user profiles and packages recreated after installation; both platforms use startup cleanup.
+
+**Deleting every organization-prefixed package** can remove optional official plugins. Explicit current and historical package inventories determine ownership.
+
+**Deleting package directories alone** lets pnpm reinstall the same old packages from retained declarations and overrides.
+
+## Consequences
+
+Production loses profile-local overrides of application-owned packages. A changed profile loses its lockfile and the next pnpm operation resolves remaining plugin dependencies again. Cleanup does not run pnpm or create rollback state; failures stop preparation and can be retried. Redirected package-parent directories fail before deletion. Focused tests cover retained plugins, declarations, repeated cleanup, development exclusion, retired packages, invalid records, and link targets.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 在 Desktop 生产启动前清理应用管理的包
+
+Status: implemented
+
+[English](2026-09-15-desktop-profile-core-cleanup.md) | 中文
+
+## Problem
+
+旧 Desktop profile 包含已安装的核心包及本地 tarball 依赖声明。即使应用携带一致的发布产物,本地包优先规则仍可能把旧 Web 前端与新插件组合起来。用户切换到已安装应用时,开发模式的回退链接也会残留。
+
+## Decision
+
+生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
+
+实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
+
+## Alternatives considered
+
+**仅在安装器中清理**会遗漏其他用户的 profile 和安装后重新产生的包;两个平台都在启动时清理。
+
+**删除组织名前缀下的所有包**可能误删可选官方插件。明确的当前及历史包清单决定归属。
+
+**仅删除包目录**会让 pnpm 根据保留的声明和 overrides 重新安装相同旧包。
+
+## Consequences
+
+生产版不再使用 profile 对应用管理包的本地覆盖。发生变化的 profile 丢弃锁文件,下次 pnpm 操作会重新解析其余插件依赖。清理不运行 pnpm,也不创建回滚状态;失败会停止准备流程,之后可以重试。重定向的包父目录会在删除前报错。定向测试覆盖插件保留、声明、重复清理、开发模式排除、退役包、无效记录及链接目标。

+ 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: 6551f2d038f64c8226b041c6f40a34df69f4ebc0
-README.zh.md: 82002f7b24e8eb513167c24e7364eb14051df4c7
+README.md: 70493cf0ba7badb72419e68470960a8a8ad33f58
+README.zh.md: c2aba1304571d6095f4a487dc1df795e64391b03

+ 1 - 1
apps/desktop/README.md

@@ -51,7 +51,7 @@ Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-al
 The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
 1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
-2. Application upgrades preserve plugin files, configuration, versions, and lockfile; pnpm does not run at startup.
+2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
 5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.

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

@@ -51,7 +51,7 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
 1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
-2. 应用升级保留插件文件、配置、版本和锁文件;启动时不运行 pnpm。
+2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
 5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。

+ 1 - 1
apps/desktop/src/main.ts

@@ -269,7 +269,7 @@ async function main(): Promise<void> {
     startup ??= (async () => {
       await navigateMain(applicationUrl)
       await backend.start(async () => {
-        await manager.applyRelease()
+        await manager.applyRelease(app.isPackaged)
       })
       // The existing Web document resumes through the boot IPC response.
     })().catch((error: unknown) => {

+ 91 - 0
apps/desktop/src/profile-core-cleanup.ts

@@ -0,0 +1,91 @@
+/** Remove application-owned packages left in external plugin profiles before production boot. */
+
+import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { dump, load } from 'js-yaml'
+import { DESKTOP_PACKAGE_SET_FILE, readDesktopCorePackageSet } from './core-package-set.ts'
+
+/** Enables the temporary production-profile cleanup; remove this module and its caller together. */
+const CLEAN_PROFILE_CORE_PACKAGES = true
+
+function object(value: unknown): Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    throw new Error('desktop profile cleanup: expected an object')
+  }
+  return value as Record<string, unknown>
+}
+
+function stat(path: string): ReturnType<typeof lstatSync> | undefined {
+  try { return lstatSync(path) } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return undefined
+  }
+}
+
+function remove(path: string): boolean {
+  const entry = stat(path)
+  if (entry === undefined) return false
+  if (entry.isSymbolicLink()) unlinkSync(path)
+  else rmSync(path, { recursive: entry.isDirectory() })
+  return true
+}
+
+function requireDirectory(path: string): void {
+  const entry = stat(path)
+  if (entry !== undefined && (!entry.isDirectory() || entry.isSymbolicLink())) {
+    throw new Error(`desktop profile cleanup: package parent is not a real directory: ${path}`)
+  }
+}
+
+function prune(value: Record<string, unknown>, field: string, names: ReadonlySet<string>): boolean {
+  if (value[field] === undefined) return false
+  const entries = object(value[field])
+  let changed = false
+  for (const name of names) {
+    if (!Object.hasOwn(entries, name)) continue
+    Reflect.deleteProperty(entries, name)
+    changed = true
+  }
+  return changed
+}
+
+/**
+ * Delete current and recorded Desktop core packages, their declarations, and stale lockfile resolutions.
+ * Managed links are unlinked without touching their targets; unrelated plugins and profile configuration survive.
+ * The caller holds the profile lock and must not have started the Host. Development launches do nothing.
+ * @param profile - Absolute Desktop profile directory.
+ * @param packageNames - Package names from the verified application runtime descriptor.
+ * @param production - Whether this launch uses the packaged application.
+ */
+export function cleanProfileCorePackages(profile: string, packageNames: readonly string[], production: boolean): void {
+  // oxlint-disable-next-line typescript/no-unnecessary-condition -- release cleanup has an explicit source switch.
+  if (!CLEAN_PROFILE_CORE_PACKAGES || !production || !existsSync(profile)) return
+  const names = new Set(packageNames)
+  const recordPath = join(profile, DESKTOP_PACKAGE_SET_FILE)
+  if (existsSync(recordPath)) {
+    for (const entry of readDesktopCorePackageSet(profile).packages) names.add(entry.name)
+  }
+  const roots = [join(profile, 'node_modules'), join(profile, '.dsh-module-fallback', 'node_modules')]
+  requireDirectory(join(profile, '.dsh-module-fallback'))
+  for (const root of roots) {
+    requireDirectory(root)
+    for (const name of names) requireDirectory(dirname(join(root, name)))
+  }
+  const manifestPath = join(profile, 'package.json')
+  const manifest = existsSync(manifestPath) ? object(JSON.parse(readFileSync(manifestPath, 'utf8'))) : undefined
+  const workspacePath = join(profile, 'pnpm-workspace.yaml')
+  const workspace = existsSync(workspacePath) ? object(load(readFileSync(workspacePath, 'utf8'))) : undefined
+  let manifestChanged = false
+  if (manifest !== undefined) {
+    for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
+      manifestChanged = prune(manifest, field, names) || manifestChanged
+    }
+    if (manifest.pnpm !== undefined) manifestChanged = prune(object(manifest.pnpm), 'overrides', names) || manifestChanged
+  }
+  const workspaceChanged = workspace !== undefined && prune(workspace, 'overrides', names)
+  const packageResidue = roots.some(root => [...names].some(name => stat(join(root, name)) !== undefined))
+  if (manifestChanged || workspaceChanged || packageResidue) remove(join(profile, 'pnpm-lock.yaml'))
+  if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`)
+  if (workspaceChanged) writeFileSync(workspacePath, dump(workspace))
+  for (const root of roots) for (const name of names) remove(join(root, name))
+}

+ 7 - 2
apps/desktop/src/project-manager.ts

@@ -29,6 +29,7 @@ import {
   unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
 } from '@deepseek-ai/dsh-app-boot'
 import { migrateDesktopProfileLinks } from './profile-packages.ts'
+import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
 
 /** Desktop plugin record derived from the installed profile. */
 export interface DesktopPluginRecord {
@@ -151,10 +152,14 @@ export class DesktopProjectManager {
     return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') }
   }
 
-  /** Load application metadata and initialize missing profile files without installing packages. */
-  async applyRelease(): Promise<void> {
+  /**
+   * Load application metadata and prepare the external plugin profile without installing packages.
+   * @param production - Remove application-owned profile packages before packaged Host startup.
+   */
+  async applyRelease(production = false): Promise<void> {
     await this.withLock(() => {
       this.descriptor = this.readRuntime()
+      cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production)
       migrateProfileSettings(this.paths.profile)
       migrateDesktopProfileLinks(this.paths.profile)
       createPluginProfile(this.paths.profile)

+ 106 - 0
apps/desktop/tests/profile-core-cleanup.spec.ts

@@ -0,0 +1,106 @@
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { load } from 'js-yaml'
+import { cleanProfileCorePackages } from '../src/profile-core-cleanup.ts'
+
+const roots: string[] = []
+const core = '@deepseek-ai/dsh-web-app'
+const extra = '@deepseek-ai/optional-plugin'
+
+function fixture(): string {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-core-cleanup-'))
+  roots.push(root)
+  mkdirSync(join(root, 'node_modules', core), { recursive: true })
+  mkdirSync(join(root, 'node_modules', extra), { recursive: true })
+  writeFileSync(join(root, 'package.json'), JSON.stringify({
+    dependencies: { [core]: 'file:./desktop-packages/web.tgz', [extra]: '1.0.0' },
+    optionalDependencies: { [core]: '1.0.0' },
+    pnpm: { overrides: { [core]: '1.0.0', third: '2.0.0' } },
+    dsh: { profile: { bundles: [core, extra] } },
+  }))
+  writeFileSync(join(root, 'pnpm-workspace.yaml'), `nodeLinker: hoisted\noverrides:\n  '${core}': file:./desktop-packages/web.tgz\n  third: 2.0.0\n`)
+  writeFileSync(join(root, 'pnpm-lock.yaml'), 'old resolutions\n')
+  writeFileSync(join(root, 'cordis.patch.yml'), '[]\n')
+  return root
+}
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+it('removes core packages and reinstall declarations while preserving optional plugins and configuration', () => {
+  const root = fixture()
+  cleanProfileCorePackages(root, [core], true)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(false)
+  expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
+  expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))).toEqual({
+    dependencies: { [extra]: '1.0.0' }, optionalDependencies: {},
+    pnpm: { overrides: { third: '2.0.0' } }, dsh: { profile: { bundles: [core, extra] } },
+  })
+  expect(load(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ nodeLinker: 'hoisted', overrides: { third: '2.0.0' } })
+  expect(readFileSync(join(root, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
+  expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(false)
+  writeFileSync(join(root, 'pnpm-lock.yaml'), 'new plugin resolutions\n')
+  cleanProfileCorePackages(root, [core], true)
+  expect(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')).toBe('new plugin resolutions\n')
+})
+
+it('does not clean development profiles', () => {
+  const root = fixture()
+  const before = readFileSync(join(root, 'package.json'), 'utf8')
+  cleanProfileCorePackages(root, [core], false)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(true)
+  expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
+  expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(true)
+})
+
+it('unlinks development fallbacks without deleting their target, including dangling links', () => {
+  const root = fixture()
+  const target = join(root, 'development-package')
+  mkdirSync(target)
+  writeFileSync(join(target, 'sentinel'), 'keep')
+  const owned = join(root, '.dsh-module-fallback', 'node_modules', core)
+  mkdirSync(join(owned, '..'), { recursive: true })
+  symlinkSync(target, owned, 'junction')
+  rmSync(join(root, 'node_modules', core), { recursive: true })
+  symlinkSync(owned, join(root, 'node_modules', core), 'junction')
+  cleanProfileCorePackages(root, [core], true)
+  expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('keep')
+  expect(existsSync(owned)).toBe(false)
+  symlinkSync(target, join(root, 'node_modules', core), 'junction')
+  rmSync(target, { recursive: true })
+  cleanProfileCorePackages(root, [core], true)
+  expect(() => lstatSync(join(root, 'node_modules', core))).toThrow()
+})
+
+it('uses the old package inventory to remove retired core names', () => {
+  const root = fixture()
+  const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', core].sort()
+  writeFileSync(join(root, 'desktop-packages.json'), JSON.stringify({ schemaVersion: 1, packages: names.map((name, index) => ({
+    name, version: '0.1.2', file: `${index}.tgz`, bytes: 1, integrity: 'sha512-YQ==',
+  })) }))
+  cleanProfileCorePackages(root, [], true)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(false)
+  expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
+})
+
+it('rejects invalid metadata before deleting packages or declarations', () => {
+  const root = fixture()
+  writeFileSync(join(root, 'desktop-packages.json'), '{"schemaVersion":1,"packages":[{"name":"../../outside"}]}')
+  const before = readFileSync(join(root, 'package.json'), 'utf8')
+  expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('invalid package record')
+  expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(true)
+})
+
+it('refuses redirected package parents without deleting their contents', () => {
+  const root = fixture()
+  const target = join(root, 'external-scope')
+  mkdirSync(join(target, 'dsh-web-app'), { recursive: true })
+  rmSync(join(root, 'node_modules', '@deepseek-ai'), { recursive: true })
+  symlinkSync(target, join(root, 'node_modules', '@deepseek-ai'), 'junction')
+  expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('not a real directory')
+  expect(existsSync(join(target, 'dsh-web-app'))).toBe(true)
+})

+ 11 - 0
apps/desktop/tests/project-manager.spec.ts

@@ -71,6 +71,17 @@ afterEach(async () => {
 })
 
 describe('desktop external plugin profile', () => {
+  it('cleans application packages only when preparing a production launch', async () => {
+    const { manager } = setup()
+    await manager.applyRelease()
+    const name = '@deepseek-ai/dsh-web-app'
+    const path = join(manager.paths.profile, 'node_modules', name)
+    mkdirSync(path, { recursive: true })
+    await manager.applyRelease()
+    expect(existsSync(path)).toBe(true)
+    await manager.applyRelease(true)
+    expect(existsSync(path)).toBe(false)
+  })
   it('reuses plugin files without scanning manifests and can disable them', async () => {
     const { manager } = setup()
     await manager.applyRelease()