Explorar el Código

fix(desktop): address shared runtime review and CI regressions

07akioni hace 2 semanas
padre
commit
ae84dd3381

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 80c11592a16c50909cde072004f86d944f709fe8
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: ddb25909c8c8906f3763e549e9485f3ffc632c7b
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: fc0821d26de1b34d6aedfa6a69daa8fe5c3e6010
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: a1dec8fb5713d89bfe6e2e98e318ff2ffa9c4ebc

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md

@@ -22,7 +22,7 @@ This note owns core resource storage and external plugin dependencies. The [pack
 
 The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
 
-The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, inherits the user environment. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
+The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts. The Host inherits the user environment. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
 
 Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md

@@ -22,7 +22,7 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
 
 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
 
-[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
+[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物。Host 继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
 
 包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 19365f6448e7cb5183d35922cff4dafdc493a93a
-2026-09-10-desktop-web-wrapper.zh.md: d78b96a772becb0d353504f7c32e0fb93811c4ff
+2026-09-10-desktop-web-wrapper.md: 063f6ae92f9e2aca6295fb1241de4bb1dac8f9a6
+2026-09-10-desktop-web-wrapper.zh.md: 9ba17a66f22741b8e548ccafbd4ba6a8af6887d0

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -16,6 +16,8 @@ The shared runner owns profile and Harness-home patches, proxy setup, telemetry
 
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled Node.js and pnpm, and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 
+Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
+
 App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
 
 Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
@@ -30,8 +32,12 @@ This partially supersedes the private composition and portless transport in the
 
 **Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
 
+**Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
+
 ## Consequences
 
 Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup, and Electron must load the ready URL instead of assuming a port or translating requests. The independent loading and recovery window remains available before the Web application starts.
 
+User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the independent recovery UI rather than triggering stricter admission checks or automatic rollback.
+
 Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -16,6 +16,8 @@ Status: implemented
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 Node.js 和 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 
+独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
+
 App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
 
 共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
@@ -30,8 +32,12 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 **保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
 
+**固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
+
 ## Consequences
 
 Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动,Electron 必须加载就绪 URL,而不是假设端口或转换请求。独立加载与恢复窗口在 Web 应用启动前仍可用。
 
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+
 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 2 - 1
apps/cli/package.json

@@ -154,6 +154,7 @@
       "types": "./lib/types/profile-boot.d.ts",
       "default": "./lib/profile-boot.js"
     },
-    "./lib/*": "./lib/*"
+    "./lib/*": "./lib/*",
+    "./package.json": "./package.json"
   }
 }

+ 2 - 0
apps/cli/src/profile-boot.ts

@@ -222,6 +222,8 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
  * then the telemetry switch.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
+ * @param fromDefaultProfile - shipped template for a missing named profile.
+ * @param resolvedProfile - application-owned profile and installation; bypasses named discovery.
  * @returns the profile and its patch layers.
  */
 async function composeProfile(

+ 59 - 0
apps/cli/tests/desktop-host.e2e.ts

@@ -0,0 +1,59 @@
+/** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */
+
+import { fork } from 'node:child_process'
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { finished } from 'node:stream/promises'
+import { expect, it, onTestFinished } from 'vitest'
+
+it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-'))
+  const modules = join(root, 'node_modules', '@deepseek-ai')
+  for (const name of ['dsh-app-boot', 'dsh']) mkdirSync(join(modules, name), { recursive: true })
+  writeFileSync(join(root, 'package.json'), '{"type":"module"}')
+  writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
+  writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
+  writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
+  writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
+    import { writeFileSync } from 'node:fs';
+    export function runProfile() {
+      process.send({ type: 'booting' });
+      return new Promise((resolve, reject) => process.once('disconnect', () => {
+        if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
+        resolve({ ctx: { connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
+          shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
+      }));
+    }
+  `)
+  const entry = join(root, 'index.js')
+  copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry)
+  const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
+  let stderr = ''
+  child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
+  const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
+  const drained = finished(child.stderr!, { cleanup: true })
+  onTestFinished(async () => {
+    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
+    await Promise.all([exited, drained])
+    rmSync(root, { recursive: true, force: true })
+  })
+  try {
+    await new Promise<void>((resolve, reject) => {
+      child.once('message', () => { resolve() })
+      child.once('error', reject)
+      child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
+    })
+    child.disconnect()
+    expect(await exited).toBe(fail ? 1 : 0)
+    await drained
+    expect(stderr).not.toContain('ERR_IPC_CHANNEL_CLOSED')
+    expect(stderr).not.toContain('Unhandled')
+    if (fail) expect(stderr).toContain('fixture boot failure')
+    else expect(readFileSync(join(root, 'stopped'), 'utf8')).toBe('stopped')
+  } finally {
+    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
+    await Promise.all([exited, drained])
+  }
+})

+ 2 - 2
apps/desktop-host/src/index.ts

@@ -31,13 +31,13 @@ async function main(): Promise<void> {
   process.once('disconnect', () => { void stop() })
   const { ctx } = await application
   const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
-  process.send?.({ type: 'ready', url })
+  if (process.connected) process.send?.({ type: 'ready', url }, (error) => { if (error !== null) console.error(error) })
 }
 
 if (import.meta.main) {
   main().catch((error: unknown) => {
     const message = error instanceof Error ? error.message : String(error)
-    process.send?.({ type: 'fatal', message })
+    if (process.connected) process.send?.({ type: 'fatal', message }, (error) => { if (error !== null) console.error(error) })
     console.error(error)
     process.exitCode = 1
     if (process.connected) process.disconnect()

+ 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: 8976fd23a5ad77f35da0182d6fb5761e96af80c9
-README.zh.md: ef1689e30975d65690e04861763419c494c3b8e6
+README.md: 5cf050d81bdcf9f7566b37c85eb2e1b414436176
+README.zh.md: d1cef944b3c20eadc2c151e1b37bf8294837033c

+ 2 - 0
apps/desktop/README.md

@@ -43,6 +43,8 @@ CLI and Desktop use the same installed-dependency inventory and bundle reconcili
 
 The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
 
+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.
+
 Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
 
 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; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.

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

@@ -43,6 +43,8 @@ CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundl
 
 加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。
 
+Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
+
 重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
 
 包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表。

+ 3 - 1
apps/desktop/src/host-process.ts

@@ -15,6 +15,8 @@ interface FatalEvent {
 
 type DesktopHostEvent = ReadyEvent | FatalEvent
 
+const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
+
 function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
   if (typeof message !== 'object' || message === null || !('type' in message)) return false
   const candidate = message as Record<string, unknown>
@@ -96,7 +98,7 @@ export class DesktopHostProcess {
     })
     this.child = child
     child.stderr?.setEncoding('utf8')
-    child.stderr?.on('data', (chunk: string) => { this.stderr += chunk })
+    child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
     child.stdout?.pipe(process.stdout)
     child.on('message', (message: unknown) => {
       if (!isDesktopHostEvent(message)) {

+ 1 - 1
apps/desktop/src/host-protocol.ts

@@ -1,4 +1,4 @@
 /** Release metadata for the Desktop Host lifecycle protocol. */
 
-/** Protocol version implemented by the Electron shell and installed dsh Host. */
+/** Lifecycle protocol generation recorded in Desktop release metadata. */
 export const DESKTOP_HOST_PROTOCOL_VERSION = 4 as const

+ 10 - 0
apps/desktop/tests/host-process.spec.ts

@@ -99,6 +99,16 @@ describe('desktop host process', () => {
     expect(failure).toHaveBeenCalledWith(new Error('dsh desktop host exited with 7: plugin crashed'))
   })
 
+  it('retains only recent diagnostics from a noisy child', async () => {
+    const runtime = projectWithHost('process.stderr.write(\'discarded-prefix\' + \'x\'.repeat(70_000) + \'recent-failure\', () => { process.exitCode = 7; process.disconnect() })')
+    const failure = await hostProcess(runtime).start().catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(Error)
+    const message = (failure as Error).message
+    expect(message).not.toContain('discarded-prefix')
+    expect(message.endsWith('recent-failure')).toBe(true)
+    expect(message.length).toBeLessThan(66_000)
+  })
+
   it('settles teardown when the executable cannot be spawned', async () => {
     const runtime = projectWithHost()
     const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime)

+ 5 - 5
apps/desktop/tests/plugin-pnpm.spec.ts

@@ -17,9 +17,9 @@ it('installs a real pnpm graph and executes scripts approved by user configurati
   const server = createServer()
   const archives = new Map<string, Buffer>()
   try {
-    for (const name of ['fixture-plugin', 'node-pty']) {
+    for (const name of ['fixture-plugin', 'fixture-script-dependency']) {
       const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin'
-        ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } }
+        ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } }
         : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"')
       writeFileSync(join(path, 'bundle.yml'), '[]\n')
       writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath}))')
@@ -39,7 +39,7 @@ it('installs a real pnpm graph and executes scripts approved by user configurati
       response.setHeader('content-type', 'application/json')
       response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {
         name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` },
-        ...(name === 'fixture-plugin' ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : {}),
+        ...(name === 'fixture-plugin' ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : { scripts: { install: 'node install.cjs' } }),
       } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } }))
     })
     const dsh = join(root, 'dsh')
@@ -56,10 +56,10 @@ it('installs a real pnpm graph and executes scripts approved by user configurati
     }
     await manager.applyRelease()
     writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`)
-    writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n  - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n  node-pty: true\n`)
+    writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n  - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n  fixture-script-dependency: true\n`)
     await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks)
     expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])
-    const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string }
+    const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/fixture-script-dependency/built.json'), 'utf8')) as { node: string }
     expect(realpathSync(built.node)).toBe(realpathSync(process.execPath))
     const entry = join(dsh, 'identity.mjs')
     writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`)

+ 1 - 1
apps/desktop/tests/profile-packages.spec.ts

@@ -42,7 +42,7 @@ afterEach(() => {
 
 it('removes recorded links and state without interpreting obsolete runtime fields', () => {
   const { profile, target } = fixture()
-  const packagePath = join(profile, 'node_modules', '@deepseek-ai', 'cordis')
+  const packagePath = join(profile, 'node_modules', '@deepseek-ai/cordis')
   link(target, packagePath)
   writeFileSync(join(target, 'package.json'), '{"name":"@deepseek-ai/cordis"}')
   writeState(profile, [{ name: '@deepseek-ai/cordis', target }])

+ 5 - 5
packages/boot/app-boot/tests/profile.spec.ts

@@ -122,13 +122,13 @@ describe('healIsolatedProfileModuleFallback', () => {
     healIsolatedProfileModuleFallback({ installAnchor: anchorB, profile: profileB })
     healIsolatedProfileModuleFallback({ installAnchor: anchorA, profile: profileA })
 
-    expect(createRequire(consumerA).resolve('commander'))
+    expect(realpathSync.native(createRequire(consumerA).resolve('commander')))
       .toBe(realpathSync.native(join(anchorA, '..', 'node_modules', 'commander', 'index.js')))
-    expect(createRequire(consumerB).resolve('commander'))
+    expect(realpathSync.native(createRequire(consumerB).resolve('commander')))
       .toBe(realpathSync.native(join(anchorB, '..', 'node_modules', 'commander', 'index.js')))
-    expect(createRequire(consumerA).resolve('pnpm-owned')).toBe(join(installed, 'index.js'))
+    expect(realpathSync.native(createRequire(consumerA).resolve('pnpm-owned'))).toBe(realpathSync.native(join(installed, 'index.js')))
     expect(readFileSync(join(installed, 'index.js'), 'utf8')).toContain('profile-installed')
-    expect(createRequire(consumerA).resolve('bundle-only'))
+    expect(realpathSync.native(createRequire(consumerA).resolve('bundle-only')))
       .toBe(realpathSync.native(join(bundleAnchor, '..', 'node_modules', 'bundle-only', 'index.js')))
     expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(webFallback)
     if (webFallback) expect(readlinkSync(sharedCommander)).toBe(sharedTarget)
@@ -136,7 +136,7 @@ describe('healIsolatedProfileModuleFallback', () => {
     healIsolatedProfileModuleFallback({ installAnchor: anchorA, profile: { ...profileA, layers: [] } })
     expect(existsSync(join(profileA.dir, 'node_modules', 'bundle-only'))).toBe(false)
     expect(existsSync(join(profileB.dir, 'node_modules', 'bundle-only'))).toBe(true)
-    expect(createRequire(consumerA).resolve('commander'))
+    expect(realpathSync.native(createRequire(consumerA).resolve('commander')))
       .toBe(realpathSync.native(join(anchorA, '..', 'node_modules', 'commander', 'index.js')))
   })
 })

+ 3 - 1
packages/terminal/terminal-bash/tests/local.spec.ts

@@ -330,7 +330,9 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => {
         timeoutMs: 8_000,
       }, 'pwsh')
       const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
-      expect(created.motd).toContain('dsh> ')
+      // Linux stdin-wait evidence can arrive before the PTY delivers the rendered prompt.
+      await expect.poll(() => ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text, { timeout: 8_000 })
+        .toContain('dsh> ')
 
       const releaseFile = join(root, 'release-command')
       // Hold the command across the silence settlement without relying on host load.

+ 11 - 0
python/sdk/tests/test_smoke_model.py

@@ -531,3 +531,14 @@ def test_profile_plugin_failure_reports_native_exit_status(monkeypatch: pytest.M
     assert f"returncode={returncode}" in message
     assert f"0x{returncode & 0xffffffff:08x}" in message
     assert "stdout='' stderr=''" in message
+
+
+@pytest.mark.parametrize("prefix", ["", "File created with exactly 18 bytes.\n\n"])
+def test_live_turn_accepts_explanation_before_final_sentinel(prefix: str) -> None:
+    SMOKE["assert_live_turn"]("create", live_result(final_response=prefix + SMOKE["LIVE_API_SENTINEL"]))
+
+
+@pytest.mark.parametrize("answer", ["", "PYTHON_SDK_LIVE_OK but the operation failed", "PYTHON_SDK_LIVE_OK\nFailure"])
+def test_live_turn_rejects_missing_final_sentinel(answer: str) -> None:
+    with pytest.raises(AssertionError, match="turn returned"):
+        SMOKE["assert_live_turn"]("create", live_result(final_response=answer))

+ 9 - 0
scripts/check-workspace-constraints.spec.ts

@@ -1,8 +1,10 @@
 /** Experimental-package publication and dependency constraints. */
 
+import { readFileSync } from 'node:fs'
 import { describe, expect, it } from 'vitest'
 import {
   checkDshFamilyVersion,
+  checkWorkspaceManifest,
   checkExperimentalDependencyIsolation,
   checkExperimentalManifest,
   expectedDshPackageFiles,
@@ -143,3 +145,10 @@ describe('package payload constraints', () => {
     ])
   })
 })
+
+it('publishes CLI runtime declarations and rejects a payload that omits them', () => {
+  const manifest = JSON.parse(readFileSync(new URL('../apps/cli/package.json', import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+  expect(checkWorkspaceManifest({ dir: 'apps/cli', manifest })).toEqual([])
+  expect(checkWorkspaceManifest({ dir: 'apps/cli', manifest: { ...manifest, files: ['lib/*.js'] } }))
+    .toEqual([expect.stringContaining('@deepseek-ai/dsh: package.json files must be ["lib/*.js","lib/types/*.d.ts"]')])
+})

+ 1 - 1
scripts/check-workspace-constraints.ts

@@ -61,7 +61,7 @@ const standardReleaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[
 const desktopApplicationDirectory = 'apps/desktop'
 const localArtifactDirs = new Set(['node_modules'])
 const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
-  '@deepseek-ai/dsh': ['lib/*.js'],
+  '@deepseek-ai/dsh': ['lib/*.js', 'lib/types/*.d.ts'],
   '@deepseek-ai/dsh-desktop-host': [
     'lib/index.js',
     'config/desktop.cordis.patch.yml',

+ 3 - 2
scripts/smoke-python-runtime.py

@@ -934,7 +934,7 @@ def smoke_sdk_live() -> None:
 
 
 def assert_live_turn(label: str, result: RunResult) -> None:
-    """Require completed model tool use and the exact smoke answer for each live turn."""
+    """Require completed model tool use and the smoke sentinel on the final answer line."""
     if result.finish_reason != "completed":
         event_types = [event.get("type") for event in result.events]
         turn_end_data = next(
@@ -951,7 +951,8 @@ def assert_live_turn(label: str, result: RunResult) -> None:
             f"{label} turn made no model-requested tool call; "
             f"final={result.final_response!r}"
         )
-    if result.final_response.strip() != LIVE_API_SENTINEL:
+    answer_lines = result.final_response.strip().splitlines()
+    if not answer_lines or answer_lines[-1].strip() != LIVE_API_SENTINEL:
         raise AssertionError(f"{label} turn returned {result.final_response!r}")
 
 def safe_turn_end(value: object) -> object: