Selaa lähdekoodia

feat(desktop): run runtime host from asar

imccyu 3 päivää sitten
vanhempi
sitoutus
fa7d5519f5

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.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-profile-resolution-generations.md
-2026-09-09-profile-resolution-generations.md: a06a527ef0988b0622370c4b6e592c6cc64af275
-2026-09-09-profile-resolution-generations.zh.md: 18e82e13b03ad024eacd8de81956cb6c5a922601
+2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5
+2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f

+ 5 - 4
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md

@@ -16,7 +16,7 @@ Profile startup computes one immutable `ResolutionGeneration` from the same depe
 
 ### One selection algorithm
 
-The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Direct callers can select link, dual, or runtime mode, while an omitted mode selects link. Runtime and dual remain internal migration and verification paths rather than user-facing launcher behavior.
+The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
 
 The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
 
@@ -74,11 +74,11 @@ Legacy disk state remains available to link-only launches, old processes, and ro
 
 Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
 
-The `dsh` launcher selects link mode when its caller omits `resolutionMode`, so supported profile startup keeps its existing filesystem behavior. Tests and low-level embedders select runtime or dual explicitly before any profile row mounts.
+The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly.
 
 Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
 
-Packaged-carrier selection, virtual-filesystem adaptation, and Electron ASAR launch behavior are separate decisions layered on this mode-neutral architecture.
+Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links.
 
 ### Performance and verification
 
@@ -106,6 +106,7 @@ Behavior tests compare the runtime generation with the disk materializer over th
 
 - One eager computation supplies the retained disk materializer and runtime generation.
 - Link-only, dual, and runtime-only tests consume the same generation; runtime startup neither writes nor retires module-resolution data.
+- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
 - ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
 - Production metadata lookup does not record Loader import results or wrap Entry, registry, tree, or HMR methods.
 - The Node compatibility matrix runs main-thread resolver specifications across supported loader interfaces; service and bootstrap specifications cover Worker environment-data and installation interfaces without launching a built Worker.
@@ -114,4 +115,4 @@ Behavior tests compare the runtime generation with the disk materializer over th
 
 ## Consequences
 
-Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the launcher default, and dual keeps a migration comparison path. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. Carrier-specific selection and virtual-filesystem launch integration remain separate work.
+Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.

+ 5 - 4
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md

@@ -16,7 +16,7 @@ profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一
 
 ### 唯一选包算法
 
-包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。直接调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。runtime 与 dual 仍是内部迁移和验证路径,不改变面向用户的 launcher 行为
+包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径
 
 安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
 
@@ -74,11 +74,11 @@ runtime-only 启动流程不创建、更新或退休 symlink 和代理包。reso
 
 link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
 
-`dsh` launcher 在调用方省略 `resolutionMode` 时选择 link 模式,因此受支持的 profile 启动保留现有文件系统行为。测试与底层嵌入方会在挂载任何 profile 条目前显式选择 runtime 或 dual。
+普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。
 
 runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
 
-打包载体选择、虚拟文件系统适配和 Electron ASAR 启动行为属于叠加在这套模式无关架构上的独立决策
+pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接
 
 ### 性能与验证
 
@@ -106,6 +106,7 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
 
 - 一次 eager 计算同时供应保留的磁盘 materializer 和运行时 generation。
 - link-only、dual 和 runtime-only 测试消费同一个 generation;runtime 启动既不写入也不退休模块解析数据。
+- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
 - ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
 - 生产 package metadata 查询不记录 Loader import 结果,也不包装 Entry、registry、tree 或 HMR 方法。
 - Node 兼容矩阵会在受支持的 loader 接口上运行主线程 resolver 规格;service 和 bootstrap 规格覆盖 Worker environment data 与安装接口,但不会启动构建后的 Worker。
@@ -114,4 +115,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
 
 ## Consequences
 
-runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持 launcher 默认值,dual 保留迁移比较路径。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。载体专用选择和虚拟文件系统启动集成仍属于独立工作。
+runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。

+ 1 - 1
apps/desktop-host/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-desktop-host",
-  "description": "Private upstream-Node host process for the Electron desktop application",
+  "description": "Private Node-mode host process for the Electron desktop application",
   "version": "0.1.5-rc.2",
   "private": true,
   "license": "MIT",

+ 28 - 11
apps/desktop-host/src/index.ts

@@ -15,9 +15,12 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
   boot,
   composeEntries,
+  createProfileResolutionGeneration,
   loadLayeredEnv,
   loadProfileDirectory,
   loadOverlayPatches,
+  PluginPackages,
+  type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
 import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
@@ -149,9 +152,20 @@ function isProjectPath(projectDir: string, target: string): boolean {
   return path === root || path.startsWith(root + sep)
 }
 
-function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
-  const dshRoot = dirname(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
-  const profile = loadProfileDirectory('dsh desktop', projectDir, join(dshRoot, 'package.json'))
+interface DesktopComposition {
+  readonly installAnchor: string
+  readonly profile: Profile
+  readonly patches: PatchOptions[]
+}
+
+function desktopComposition(
+  runtimeDir: string,
+  projectDir: string,
+  allowLinkedPackages: boolean,
+): DesktopComposition {
+  const installAnchor = packageManifestPath(runtimeDir, '@deepseek-ai/dsh')
+  const dshRoot = dirname(installAnchor)
+  const profile = loadProfileDirectory('dsh desktop', projectDir, installAnchor)
   for (const layer of profile.layers) {
     if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) {
       throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`)
@@ -173,7 +187,7 @@ function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPacka
       },
     }])
   }
-  return layers.flat()
+  return { installAnchor, profile, patches: layers.flat() }
 }
 
 function dshVersion(runtimeDir: string): string {
@@ -282,19 +296,22 @@ export async function runDesktopHost(
   writeResponse: (frame: Buffer) => Promise<void>,
   options: { allowLinkedPackages?: boolean } = {},
 ): Promise<DesktopHostController> {
+  const absoluteRuntime = resolve(runtimeDir)
   const absoluteProject = resolve(projectDir)
   mkdirSync(absoluteProject, { recursive: true })
   const rootConfig = join(absoluteProject, ROOT_CONFIG_FILENAME)
   writeFileSync(rootConfig, ROOT_CONFIG)
   const environment = loadLayeredEnv('dsh desktop')
+  const composition = desktopComposition(absoluteRuntime, absoluteProject, options.allowLinkedPackages === true)
+  const resolution = await createProfileResolutionGeneration({
+    installAnchor: composition.installAnchor,
+    profile: composition.profile,
+  })
   let current: Context | undefined
-  const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches(
-    resolve(runtimeDir),
-    absoluteProject,
-    options.allowLinkedPackages === true,
-  )), (hostCtx) => {
+  const ctx = await boot('dsh desktop', rootConfig, structuredClone(composition.patches), async (hostCtx) => {
     current = hostCtx
     hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment)
+    await hostCtx.plugin(PluginPackages, { generation: resolution })
     provideCmdline(hostCtx, { args: [], exit: () => {} })
   })
   current = ctx
@@ -306,7 +323,7 @@ export async function runDesktopHost(
     throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules')
   }
   const api = connection.createSharedFetchHandler('/api')
-  const assets = assetHandler(ctx, resolve(runtimeDir))
+  const assets = assetHandler(ctx, absoluteRuntime)
   const streams = remoteStreamHandler(ctx)
   const requests = new Map<number, AbortController>()
   let disposing: Promise<void> | undefined
@@ -322,7 +339,7 @@ export async function runDesktopHost(
   }
 
   return {
-    dshVersion: dshVersion(resolve(runtimeDir)),
+    dshVersion: dshVersion(absoluteRuntime),
     cancel(streamId) {
       requests.get(streamId)?.abort()
     },

+ 9 - 4
apps/desktop/electron-builder.config.d.mts

@@ -4,11 +4,16 @@ export interface DesktopElectronBuilderConfig {
   readonly directories: {
     readonly output: string
   }
-  readonly extraResources: readonly [
-    { readonly from: string, readonly to: 'runtime' },
-    { readonly from: string, readonly to: 'dsh' },
-    { readonly from: string, readonly to: 'dsh/node_modules' },
+  readonly files: readonly [
+    string,
+    string,
+    string,
+    string,
+    { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] },
+    { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] },
   ]
+  readonly asarUnpack: readonly string[]
+  readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }]
   readonly mac: {
     readonly identity: string | undefined
     readonly forceCodeSigning: boolean

+ 11 - 13
apps/desktop/electron-builder.config.mjs

@@ -63,20 +63,26 @@ export function createElectronBuilderConfig(
       'lib/*.cjs',
       'renderer/**/*',
       'package.json',
+      { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
+      // electron-builder excludes a source directory's root node_modules.
+      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
+    ],
+    asarUnpack: [
+      '**/*.{node,dylib,dll,so,exe}',
+      '**/*.so.*',
+      '**/spawn-helper',
+      '**/@vscode/ripgrep/bin/rg',
     ],
     extraResources: [
       { from: buildPaths.runtime, to: 'runtime' },
-      { from: buildPaths.dsh, to: 'dsh' },
-      // electron-builder excludes a source directory's root node_modules.
-      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' },
     ],
     mac: {
       category: 'public.app-category.developer-tools',
       identity: macOSSigning?.signingIdentity,
       forceCodeSigning: true,
       hardenedRuntime: true,
-      // Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
-      signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'],
+      // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
+      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '\\.pak$'],
       notarize: true,
       target: ['dmg', 'zip'],
     },
@@ -84,16 +90,8 @@ export function createElectronBuilderConfig(
       sign: true,
       writeUpdateInfo: false,
     },
-    afterPack: async context => {
-      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
-        context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
-    },
     afterSign: async context => {
       if (context.electronPlatformName !== 'darwin') return
-      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
-        context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch })
       verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
     },
     artifactBuildCompleted: artifact => {

+ 11 - 8
apps/desktop/src/host-process.ts

@@ -1,4 +1,4 @@
-/** Upstream-Node child lifecycle and streaming custom-protocol carrier. */
+/** Node-compatible child lifecycle and streaming custom-protocol carrier. */
 
 import { spawn, type ChildProcess } from 'node:child_process'
 import { once } from 'node:events'
@@ -65,7 +65,7 @@ export interface DesktopHostReady {
   readonly dshVersion: string
 }
 
-/** One dsh backend running under the bundled upstream Node.js executable. */
+/** One dsh backend running under an owned Node-compatible executable. */
 export class DesktopHostProcess {
   private child: ChildProcess | undefined
   private requestPipe: Writable | undefined
@@ -86,7 +86,7 @@ export class DesktopHostProcess {
   private failureReported = false
 
   /**
-   * @param node - absolute bundled upstream Node.js executable.
+   * @param executable - absolute upstream Node.js or Electron executable.
    * @param runtimeDir - immutable packages carried by the current application.
    * @param projectDir - active or staged desktop plugin profile.
    * @param inspectPort - optional loopback inspector port for workspace development.
@@ -94,7 +94,7 @@ export class DesktopHostProcess {
    * @param onFailure - Receives the first fatal child or transport failure, including after readiness.
    */
   constructor(
-    private readonly node: string,
+    private readonly executable: string,
     private readonly runtimeDir: string,
     private readonly projectDir: string,
     private readonly inspectPort?: number,
@@ -106,7 +106,7 @@ export class DesktopHostProcess {
   async start(): Promise<DesktopHostReady> {
     if (this.child !== undefined) return this.readyPromise
     const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
-    const child = spawn(this.node, [
+    const child = spawn(this.executable, [
       ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
       entry,
       this.runtimeDir,
@@ -114,9 +114,12 @@ export class DesktopHostProcess {
       ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']),
     ], {
       cwd: this.projectDir,
-      env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
-        name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
-      ))),
+      env: {
+        ...Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
+          name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
+        ))),
+        ELECTRON_RUN_AS_NODE: '1',
+      },
       stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
     })
     const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD]

+ 8 - 4
apps/desktop/src/main.ts

@@ -65,16 +65,20 @@ interface RuntimeResources {
   readonly node: string
   readonly pnpm: string
   readonly dsh: string
+  readonly profileResolution?: 'runtime'
 }
 
 function runtimeResources(): RuntimeResources {
   const development = !app.isPackaged
-  const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : undefined)
-    ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
+  const node = development
+    ? process.env.DSH_DESKTOP_NODE_BINARY
+      ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
+    : process.execPath
   const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
     ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
-  const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh')
-  return { node, pnpm, dsh }
+  const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
+    ?? (development ? join(process.resourcesPath, 'dsh') : join(app.getAppPath(), 'dsh'))
+  return { node, pnpm, dsh, ...(development ? {} : { profileResolution: 'runtime' }) }
 }
 
 function developmentHostInspectPort(enabled: boolean): number | undefined {

+ 39 - 8
apps/desktop/src/profile-packages.ts

@@ -120,6 +120,19 @@ export function linkDesktopHostPackages(profile: string, root: string, runtime:
   writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 })
 }
 
+/**
+ * Record a runtime-resolved profile without changing links left by an earlier release.
+ * @param profile - Active Desktop profile.
+ * @param runtime - Verified release descriptor supplying the runtime generation.
+ */
+export function recordDesktopRuntimeProfile(profile: string, runtime: DesktopRuntimeDescriptor): void {
+  const links = readDesktopProfileState(profile)?.links ?? []
+  const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version,
+    nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch,
+    lockHash: desktopPluginLockHash(profile), links }
+  writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 })
+}
+
 interface PackageManifest {
   readonly name: string
   readonly version: string
@@ -167,16 +180,28 @@ function packageFrom(anchor: string, name: string): string | undefined {
  * @param root - Immutable runtime directory.
  * @param runtime - Verified shared package inventory.
  * @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required.
+ * @param resolutionMode - Whether host packages are linked or supplied by a runtime generation.
  */
 export function validateDesktopPluginGraph(
   profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[],
+  resolutionMode: 'link' | 'runtime' = 'link',
 ): void {
   const profileRoot = realpathSync.native(profile)
   const shared = new Map(runtime.sharedPackages.map((entry) => {
-    return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const
+    const path = runtimePath(root, entry.path)
+    let canonical: string
+    try {
+      canonical = realpathSync.native(path)
+    } catch (error) {
+      if (!existsSync(path)) throw error
+      canonical = resolve(path)
+    }
+    return [entry.name, { path: canonical, version: entry.version }] as const
   }))
-  for (const [name, path] of shared) {
-    if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
+  if (resolutionMode === 'link') {
+    for (const [name, entry] of shared) {
+      if (packageFrom(profile, name) !== entry.path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
+    }
   }
   if (activePlugins.length === 0) return
   const scanned = new Set<string>()
@@ -195,7 +220,7 @@ export function validateDesktopPluginGraph(
       const info = manifest(canonical)
       const host = shared.get(info.name)
       if (host !== undefined) {
-        if (canonical !== host || path !== join(profile, 'node_modules', info.name)) {
+        if (resolutionMode !== 'runtime' && (canonical !== host.path || path !== join(profile, 'node_modules', info.name))) {
           throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`)
         }
         continue
@@ -215,19 +240,25 @@ export function validateDesktopPluginGraph(
     for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) {
       const peer = name in info.peerDependencies
       const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies
+      const host = shared.get(name)
+      if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`)
+      if (host !== undefined) {
+        if (peer && !satisfies(host.version, range)) {
+          throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${host.version}`)
+        }
+        continue
+      }
       const target = packageFrom(path, name)
       if (target === undefined && optional) continue
       if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`)
-      const host = shared.get(name)
-      if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`)
-      if (host !== undefined ? target !== host : !inside(profileRoot, target)) {
+      if (!inside(profileRoot, target)) {
         throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`)
       }
       const dependency = manifest(target)
       if (peer && !satisfies(dependency.version, range)) {
         throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`)
       }
-      if (host === undefined) visit(target, `${chain} -> ${name}`)
+      visit(target, `${chain} -> ${name}`)
     }
   }
   for (const name of activePlugins) {

+ 17 - 9
apps/desktop/src/project-manager.ts

@@ -28,7 +28,7 @@ import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
 import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
-  desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
+  desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, recordDesktopRuntimeProfile,
   unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
 } from './profile-packages.ts'
 
@@ -57,6 +57,8 @@ export interface DesktopRuntimeExecutables {
   readonly node: string
   readonly pnpm: string
   readonly dsh: string
+  /** How the Host obtains release-owned packages outside the writable profile. */
+  readonly profileResolution?: 'link' | 'runtime'
 }
 
 /** Hooks that stop the backend before profile writes and restart it after success. */
@@ -298,8 +300,10 @@ export class DesktopProjectManager {
 
   private prepareProfile(projectDir: string): void {
     const runtime = this.currentRuntime()
-    linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
-    validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir))
+    const resolutionMode = this.runtime.profileResolution ?? 'link'
+    if (resolutionMode === 'runtime') recordDesktopRuntimeProfile(projectDir, runtime)
+    else linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
+    validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir), resolutionMode)
   }
 
   /** Read release metadata and reconcile its external profile without installing core packages. */
@@ -310,10 +314,10 @@ export class DesktopProjectManager {
       const previous = readDesktopProfileState(this.paths.profile)
       if (!existsSync(this.pendingPackages) && previous?.runtimeId === desktopRuntimeId(target)
         && previous.lockHash === desktopPluginLockHash(this.paths.profile)
-        && previous.links.length === target.sharedPackages.length
-        && previous.links.every(link => existsSync(link.target)
+        && (this.runtime.profileResolution === 'runtime' || (previous.links.length === target.sharedPackages.length
+          && previous.links.every(link => existsSync(link.target)
           && existsSync(join(this.paths.profile, 'node_modules', link.name))
-          && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) {
+          && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))))) {
         return false
       }
       if (previous === undefined) createPluginProfile(this.paths.profile)
@@ -340,11 +344,15 @@ export class DesktopProjectManager {
       }
       const previous = readDesktopProfileState(this.paths.profile)
       const packagesChanged = mutation.type !== 'plugin-toggle'
-      if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile)
+      if (packagesChanged && this.runtime.profileResolution !== 'runtime') unlinkDesktopHostPackages(this.paths.profile)
       try {
         await this.applyMutation(this.paths.profile, mutation)
       } finally {
-        if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime())
+        if (packagesChanged) {
+          const runtime = this.currentRuntime()
+          if (this.runtime.profileResolution === 'runtime') recordDesktopRuntimeProfile(this.paths.profile, runtime)
+          else linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, runtime)
+        }
       }
       await this.reconcileProfile(this.paths.profile, previous, packagesChanged)
       await hooks.afterChange()
@@ -358,7 +366,7 @@ export class DesktopProjectManager {
       && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch))
     if (rebuild) {
       writeFileSync(this.pendingPackages, '')
-      unlinkDesktopHostPackages(projectDir)
+      if (this.runtime.profileResolution !== 'runtime') unlinkDesktopHostPackages(projectDir)
       removeOwnedDirectory(join(projectDir, 'node_modules'))
       await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts'])
     }

+ 2 - 2
apps/desktop/tests/host-process.spec.ts

@@ -105,7 +105,7 @@ process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' })
 function onRequestFrame(frame) {
   if (frame.type !== 1) return
   responseStart(frame.streamId)
-  responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH}))
+  responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH, runAsNode: process.env.ELECTRON_RUN_AS_NODE}))
   responseEnd(frame.streamId)
 }
 `)
@@ -116,7 +116,7 @@ function onRequestFrame(frame) {
     })
     try {
       const response = await host.fetch(new Request('dsh-app://app/environment'))
-      expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) })
+      expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile), runAsNode: '1' })
     } finally { await host.stop() }
   })
 

+ 12 - 36
apps/desktop/tests/macos-signature.spec.ts

@@ -1,10 +1,3 @@
-import { mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join, relative } from 'node:path'
-import { createRequire } from 'node:module'
-import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js'
-import { runtimeFixture } from './runtime-fixture.ts'
-import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import type { NotarizeOptions } from '@electron/notarize'
 import {
@@ -18,11 +11,6 @@ import {
   assertMacOSSignatureDetails,
 } from '../scripts/verify-macos-signature.mjs'
 
-// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter.
-const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as {
-  copyFiles: (matchers: FileMatcher[]) => Promise<void>
-}
-
 const RELEASE_ENVIRONMENT = {
   DSH_DESKTOP_APP_ID: 'com.example.desktop',
   DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
@@ -52,18 +40,26 @@ describe('desktop macOS release signature', () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
     expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
-    expect(config.extraResources).toHaveLength(3)
+    expect(config.extraResources).toHaveLength(1)
     expect(config.extraResources[0]?.to).toBe('runtime')
-    expect(config.extraResources[1]?.to).toBe('dsh')
     expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime')
-    expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh')
+    expect(config.files.slice(-2).map(entry => typeof entry === 'string' ? entry : {
+      ...entry, from: portablePath(entry.from),
+    })).toEqual([
+      expect.objectContaining({ from: expect.stringContaining('/.desktop-build/targets/mac-arm64/dsh'), to: 'dsh' }),
+      expect.objectContaining({ from: expect.stringContaining('/.desktop-build/targets/mac-arm64/dsh/node_modules'), to: 'dsh/node_modules' }),
+    ])
+    expect(config.asarUnpack).toEqual(expect.arrayContaining([
+      '**/*.{node,dylib,dll,so,exe}',
+      '**/@vscode/ripgrep/bin/rg',
+    ]))
     expect(config).toMatchObject({
       appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
       mac: {
         identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY,
         forceCodeSigning: true,
         notarize: true,
-        signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'],
+        signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '\\.pak$'],
       },
       dmg: {
         sign: true,
@@ -92,26 +88,6 @@ describe('desktop macOS release signature', () => {
     ]) expect(ignored(path)).toBe(false)
   })
 
-  it('copies the complete runtime despite electron-builder excluding root node_modules', async () => {
-    const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
-    const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
-    const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-'))
-    try {
-      const source = join(root, 'source')
-      const destination = join(root, 'resources')
-      runtimeFixture(source)
-      const sourceRoot = config.extraResources[1].from
-      const matchers = config.extraResources.slice(1).map(entry => new FileMatcher(
-        join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value,
-      ))
-      await copyFiles(matchers.slice(0, 1))
-      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u)
-      rmSync(destination, { recursive: true })
-      await copyFiles(matchers)
-      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } })
-    } finally { rmSync(root, { recursive: true, force: true }) }
-  })
-
   it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     expect(() => createElectronBuilderConfig({

+ 7 - 4
apps/desktop/tests/main-startup.spec.ts

@@ -12,6 +12,7 @@ const harness = await vi.hoisted(async () => {
   }
   const windows: FakeWindow[] = []
   const hosts: FakeHost[] = []
+  const managerRuntimes: unknown[] = []
   const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
   let pluginsEnabled = false
   let preparing = deferred()
@@ -73,7 +74,7 @@ const harness = await vi.hoisted(async () => {
     }),
   })
   return {
-    windows, hosts, handlers, app, FakeWindow, FakeHost,
+    windows, hosts, managerRuntimes, handlers, app, FakeWindow, FakeHost,
     dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     assertProfileRuntime: vi.fn(),
@@ -85,7 +86,7 @@ const harness = await vi.hoisted(async () => {
     get pluginsEnabled() { return pluginsEnabled },
     set pluginsEnabled(value: boolean) { pluginsEnabled = value },
     reset() {
-      windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
+      windows.length = 0; hosts.length = 0; managerRuntimes.length = 0; handlers.clear(); app.removeAllListeners()
       app.isPackaged = true
       pluginsEnabled = false
       preparing = deferred(); prepared = deferred(); hostStarted = deferred()
@@ -110,6 +111,7 @@ vi.mock('../src/project-manager.ts', () => ({
     readonly applyRelease = harness.applyRelease
     readonly assertProfileRuntime = harness.assertProfileRuntime
     canRecoverProfile = harness.canRecoverProfile
+    constructor(_paths: unknown, runtime: unknown) { harness.managerRuntimes.push(runtime) }
     async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
       await hooks.beforeChange()
       harness.pluginsEnabled = false
@@ -299,10 +301,11 @@ describe('desktop main startup', () => {
     expect(harness.applyRelease).toHaveBeenCalledTimes(1)
     expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile')
     expect(harness.hosts[0]).toMatchObject({
-      node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'),
-      runtime: join('desktop-test-resources', 'dsh'),
+      node: process.execPath,
+      runtime: join(harness.app.getAppPath(), 'dsh'),
       profile: 'desktop-test-profile',
     })
+    expect(harness.managerRuntimes[0]).toMatchObject({ profileResolution: 'runtime' })
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html'])

+ 18 - 2
apps/desktop/tests/profile-packages.spec.ts

@@ -1,11 +1,17 @@
 import { execFileSync } from 'node:child_process'
-import { mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { lstatSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, expect, it } from 'vitest'
 import { createPluginProfile } from '../src/project-manager.ts'
-import { linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
+import {
+  linkDesktopHostPackages,
+  readDesktopProfileState,
+  recordDesktopRuntimeProfile,
+  unlinkDesktopHostPackages,
+  validateDesktopPluginGraph,
+} from '../src/profile-packages.ts'
 import { runtimeFixture, writePackage } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -34,6 +40,16 @@ it('loads one shared ESM instance from both host and external plugin while keepi
   const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } })
   expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' })
 })
+it('runtime resolution retains and ignores an existing Link generation', () => {
+  const { dsh, runtime, profile } = fixture()
+  const links = readDesktopProfileState(profile)?.links
+  expect(links?.length).toBeGreaterThan(0)
+
+  recordDesktopRuntimeProfile(profile, runtime)
+  expect(readDesktopProfileState(profile)?.links).toEqual(links)
+  expect(lstatSync(join(profile, 'node_modules/@deepseek-ai/cordis')).isSymbolicLink()).toBe(true)
+  expect(() => { validateDesktopPluginGraph(profile, dsh, runtime, [], 'runtime') }).not.toThrow()
+})
 it.each(['nested', 'alias'])('rejects a %s second copy of a host package', (placement) => {
   const { dsh, runtime, profile } = fixture()
   const plugin = writePackage(join(profile, 'node_modules'), 'plugin')

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

@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
 import { resolveDesktopPaths } from '../src/paths.ts'
 import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts'
+import { readDesktopProfileState } from '../src/profile-packages.ts'
 import { runtimeFixture } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -70,6 +71,24 @@ afterEach(async () => {
 })
 
 describe('desktop external plugin profile', () => {
+  it('changes runtime generations without deleting legacy host links', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    const links = readDesktopProfileState(manager.paths.profile)?.links
+    expect(links?.length).toBeGreaterThan(0)
+
+    const dsh = join(root, 'next-runtime', 'dsh')
+    runtimeFixture(dsh, '1.1.0')
+    const runtimeManager = new DesktopProjectManager(manager.paths, {
+      ...manager.runtime,
+      dsh,
+      profileResolution: 'runtime',
+    })
+    await expect(runtimeManager.applyRelease()).resolves.toBe(true)
+    expect(readDesktopProfileState(manager.paths.profile)?.links).toEqual(links)
+    await expect(runtimeManager.applyRelease()).resolves.toBe(false)
+  })
+
   it('reuses plugin files without scanning manifests and can disable or reset them', async () => {
     const { manager } = setup()
     await manager.applyRelease()

+ 4 - 1
packages/fs/tool-fs-search/src/search-core.ts

@@ -175,7 +175,10 @@ export function resolveRgPath(): Promise<string> {
       ? join(executable.dir, `${executable.name}-rg.exe`)
       : `${process.execPath}-rg`
     if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar
-    return (await import('@vscode/ripgrep')).rgPath
+    const dependency = (await import('@vscode/ripgrep')).rgPath
+    return process.versions.electron === undefined
+      ? dependency
+      : dependency.replace(/\.asar(?=[\\/])/u, '.asar.unpacked')
   })
   return rgPathPromise
 }

+ 18 - 5
packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts

@@ -1,8 +1,8 @@
 import { join, parse } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
-const { dependencyRgPath, existsSync } = vi.hoisted(() => ({
-  dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg',
+const { dependency, existsSync } = vi.hoisted(() => ({
+  dependency: { rgPath: '/node_modules/@vscode/ripgrep/bin/rg' },
   existsSync: vi.fn(),
 }))
 const originalPlatform = process.platform
@@ -13,18 +13,21 @@ vi.mock('node:fs', async (importOriginal) => {
   return { ...actual, existsSync }
 })
 
-vi.mock('@vscode/ripgrep', () => ({ rgPath: dependencyRgPath }))
+vi.mock('@vscode/ripgrep', () => ({ get rgPath() { return dependency.rgPath } }))
 
 beforeEach(() => {
   vi.resetModules()
   existsSync.mockReset()
+  dependency.rgPath = '/node_modules/@vscode/ripgrep/bin/rg'
   Reflect.deleteProperty(process, 'pkg')
+  Reflect.deleteProperty(process.versions, 'electron')
   Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform })
   process.execPath = originalExecPath
 })
 
 afterEach(() => {
   Reflect.deleteProperty(process, 'pkg')
+  Reflect.deleteProperty(process.versions, 'electron')
   Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform })
   process.execPath = originalExecPath
 })
@@ -58,7 +61,7 @@ describe('ripgrep resolution', () => {
     existsSync.mockReturnValue(true)
     const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
 
-    await expect(resolveRgPath()).resolves.toBe(dependencyRgPath)
+    await expect(resolveRgPath()).resolves.toBe(dependency.rgPath)
     expect(existsSync).not.toHaveBeenCalled()
   })
 
@@ -67,11 +70,21 @@ describe('ripgrep resolution', () => {
     existsSync.mockReturnValue(false)
     const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
 
-    await expect(resolveRgPath()).resolves.toBe(dependencyRgPath)
+    await expect(resolveRgPath()).resolves.toBe(dependency.rgPath)
     const executable = parse(process.execPath)
     const sidecar = process.platform === 'win32'
       ? join(executable.dir, `${executable.name}-rg.exe`)
       : `${process.execPath}-rg`
     expect(existsSync).toHaveBeenCalledWith(sidecar)
   })
+
+  it('uses the unpacked executable path for an Electron ASAR dependency', async () => {
+    Reflect.defineProperty(process.versions, 'electron', { configurable: true, value: '44.0.0' })
+    dependency.rgPath = '/Applications/DeepSeek Harness.app/Contents/Resources/app.asar/dsh/node_modules/@vscode/ripgrep/bin/rg'
+    const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
+
+    await expect(resolveRgPath()).resolves.toBe(
+      '/Applications/DeepSeek Harness.app/Contents/Resources/app.asar.unpacked/dsh/node_modules/@vscode/ripgrep/bin/rg',
+    )
+  })
 })