为 anywhere-labs/deepseek-harness-desktop 开发插件:从 Cordis Service 到安全安装链路

0 阅读15分钟

摘要

作为一个经常为可扩展系统设计插件接口的开发者,我认为判断插件生态是否成熟,不能只看“能不能加载第三方包”,更要看宿主是否给出了稳定的能力边界、清晰的生命周期和可验证的失败语义。本文专门研究社区仓库 [anywhere-labs/deepseek-harness-desktop](https://github.com/anywhere-labs/deepseek-harness-desktop "anywhere-labs/deepseek-harness-desktop") 的插件开发方式,而不是泛泛介绍 Electron 插件,也不是介绍官方 [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness "deepseek-ai/deepseek-harness") 的全部 API。DSH Desktop 没有为了桌面端再造 preload/IPC 插件平台,而是继续让普通 DSH Host 插件、Web Client module、route、RPC、service 与 slot 工作在原来的 Cordis 组合图中;桌面层只额外发布 desktopProfilesdesktopPnpm 两个 Host service,分别解决“当前究竟运行哪个 Profile”和“怎样使用安装包内置工具安全地修改这个 Profile”两个问题。这个看似很窄的接口背后,包含了不少容易踩坑的工程细节:current 为什么必须在一个 generation 内不可变,select() 为什么代表重启而不是赋值,run() 为什么不能替代 runPlugin(),为什么输出流、超时、退出码和终止信号都要由调用方处理,跨普通 DSH/Desktop 的插件又为什么不能把桌面 service 放进顶层 required injection。我尤其关心那些示例代码经常略过、上线后却会造成真实故障的部分,例如用户连点安装导致 lockfile 竞争、页面关闭后子进程仍然存活、相对本地包被解析到错误目录,以及把“命令已退出”误认为“整棵进程树已经释放”。接下来我会结合源码 contract、精简 TypeScript 示例、调用流程图与测试清单,给出一条从环境探测、用户操作、受管子进程到下一代 Loader 激活的完整实践路径。读完后,你应该能够写出既遵守 Desktop 边界、又保留普通 DSH 兼容性的插件,而不是依赖 argv、全局 PATH 或 Electron 私有对象碰运气。

项目身份说明:本文只讨论社区项目 anywhere-labs/deepseek-harness-desktop 2.x 暴露的 Desktop 插件能力。该项目基于 DeepSeek Harness 构建,但不是 DeepSeek 官方产品。

图1 DSH Desktop 界面:插件最终仍通过普通 Web surface 与用户交互

一、先分清三类扩展

在 DSH Desktop 中,“插件”至少涉及三种不同层次。普通 Host 插件运行在 Electron main 进程里的 Cordis Host generation 中,可以提供 service、命令、路由和业务逻辑;Web Client module 运行在沙箱 renderer 中,通过 client metadata 与 slot 进入官方 Web UI;Desktop 自有插件则负责窗口、托盘、终端、Profile 与更新等原生功能。第三方包通常只需要前两类,不应把自己变成 Desktop 内部模块。

| 扩展类型 | 运行位置 | 适合提供的能力 | 不应做的事 | | --- | --- | --- | --- | | 普通 DSH Host 插件 | Electron main 内的 Host Cordis | service、route、RPC、任务与包管理编排 | 直接缓存 BrowserWindow | | Web Client module | Chromium sandbox renderer | 页面、slot contribution、进度展示 | 直接访问 Node.js 或 Host service | | Desktop 专用 Host 插件 | 同一 Host generation | 消费 desktopProfilesdesktopPnpm | 依赖 launcher 私有 bootstrap | | Desktop 内部 row | Desktop 包内部 | shell、tray、terminal、updates | 被第三方当成稳定 API |

flowchart LR
    subgraph Main["Electron Main Process"]
        L["Launcher"]
        H["Cordis Host Generation"]
        PR["desktopProfiles"]
        PN["desktopPnpm"]
        UP["上游 Services<br/>webServer·subprocess·settings"]
        TP["第三方 Host 插件"]
    end
    subgraph Browser["Sandboxed Renderer"]
        WC["第三方 Web Client"]
        UI["官方 Web UI / Slots"]
    end
    L --> PR
    L --> PN
    UP --> PN
    TP --> PR
    TP --> PN
    TP <-->|"普通 route / RPC"| WC
    WC --> UI

    classDef launcher fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px;
    classDef service fill:#7c3aed,color:#fff,stroke:#5b21b6,stroke-width:2px;
    classDef plugin fill:#059669,color:#fff,stroke:#047857,stroke-width:2px;
    classDef web fill:#f59e0b,color:#111827,stroke:#d97706,stroke-width:2px;
    class L launcher;
    class PR,PN,UP service;
    class TP plugin;
    class WC,UI web;

图2 插件数据流:Host service 不会穿过隐藏的 Electron IPC 直接进入页面

这个边界首先解决安全问题,其次解决可移植性问题。包含 UI 的插件继续发布普通 dsh.client 元数据并使用 Web route/RPC;即使离开 Desktop,在 dsh web 中也能沿用同一模块图。

二、公开 Contract 只有两个

插件作者应从受支持的 package export 做 type-only import:

import type {
  DesktopCurrentProfile,
  DesktopProfiles,
} from 'dsh-plugin-desktop/profile-service'
import type {
  DesktopPnpm,
  DesktopPnpmHandle,
} from 'dsh-plugin-desktop/pnpm'

这里容易混淆的是,dsh-plugin-desktop/profiles 是 Desktop 自有托盘 consumer,不是 service contract;desktopRuntimedesktopPnpmBootstrap 即使出现在生成的声明文件中,也分别属于内部原生 adapter 和 launcher 私有事实,第三方不得 inject。

| 名称 | 是否公开 | 稳定用途 | | --- | --- | --- | | ctx.desktopProfiles | 是 | 读取当前 Profile、只读发现、请求切换 | | ctx.desktopPnpm | 是 | 对当前 Profile 执行受管 pnpm/DSH plugin operation | | ctx.desktopRuntime | 否 | Desktop 自有窗口、托盘、终端与更新 adapter | | desktopPnpmBootstrap | 否 | 打包路径、Electron ABI、Node helper 等启动事实 | | Electron BrowserWindow | 否 | 原生窗口实现细节,不属于插件 contract |

窄接口不是限制生态,而是保证生态不必跟着 Electron 窗口结构、ASAR 路径或私有 shim 一起升级。

三、正确理解 desktopProfiles

desktopProfiles.current 提供当前 Profile 的名称和绝对目录。它是本次 generation 的不可变快照,不能从 process.argv、settings、URL、ctx.baseUrl$DSH_HOME 猜测替代。

interface DesktopProfiles {
  readonly current: {
    readonly name: string
    readonly dir: string
  }
  list(): readonly DesktopProfileSummary[]
  select(name: string): Promise<void>
}

list() 是只读发现,会重新读取 Profile manifest,但不会修改 patch、dependency 或 bundle 顺序。返回列表中可能有“可见但不可选”的 Profile,例如缺少 Web 能力、配置损坏或已嵌套 Desktop layer 的组合,界面应根据返回状态禁用,而不是擅自修复。

select(name) 的含义则更重要:它先把目标作为 pending 持久化,再请求当前 Cordis tree 有序 teardown 与 Electron relaunch。Promise 完成只表示重启请求已被接受,不代表旧 generation 中的 current 已改变。任何跨重启保存的 service reference 都已经过期。

flowchart TD
    A["读取 desktopProfiles.current"] --> B["展示 list() 结果"]
    B --> C{"用户是否切换?"}
    C -- "否" --> A
    C -- "是" --> D["select(target)"]
    D --> E["目标持久化为 pending"]
    E --> F["dispose 当前 generation"]
    F --> G["Electron relaunch"]
    G --> H["重新获取新 service"]
    H --> A

    classDef read fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px;
    classDef decide fill:#f59e0b,color:#111827,stroke:#b45309,stroke-width:2px;
    classDef restart fill:#ef4444,color:#fff,stroke:#b91c1c,stroke-width:2px;
    class A,B,H read;
    class C decide;
    class D,E,F,G restart;

图3 Profile 切换不是状态赋值,而是跨 generation 的重启事务

并发调用也有明确语义:同一目标共享 operation;一个目标已经提交后,另一个目标在重启前会被拒绝;持久化失败会释放选择槽,重启失败则保留已提交目标供同目标重试。这能阻止多个 UI 点击互相覆盖磁盘状态。

四、run() 和 runPlugin() 不能混用

desktopPnpm 提供两个看似相近、实际语义不同的方法:

| 方法 | 实际执行 | cwd | 推荐用途 | | --- | --- | --- | --- | | run(args) | 打包的 pnpm JavaScript entry | 当前 Profile 目录 | 明确不需要 DSH reconcile 的低层操作 | | runPlugin(args, invokingDir) | 打包的 dsh plugin --profile <active> | 调用方绝对目录 | add、remove、update、install、依赖修复 |

插件管理必须优先使用 runPlugin()。普通 pnpm 只会改变依赖树,并不保证首次 Profile 初始化、相对 file:/link: source 以调用目录为锚点,也不保证成功后把包同步到 dsh.profile.bundles。错误地调用 run(),可能得到“node_modules 已有包,但 Loader 永远看不到”的半完成状态。

const signal = AbortSignal.timeout(5 * 60_000)
const operation = ctx.desktopPnpm.runPlugin(
  ['add', target],
  process.cwd(), // 必须是绝对目录,保留相对 package spec 的语义
  signal,
)

operation.stdout.setEncoding('utf8')
operation.stderr.setEncoding('utf8')
operation.stdout.on('data', chunk => reportProgress(String(chunk)))
operation.stderr.on('data', chunk => reportWarning(String(chunk)))

const result = await operation.done
if (result.exitCode !== 0 || result.signal !== null) {
  throw new Error(`install failed: code=${result.exitCode}, signal=${result.signal}`)
}

参数会作为 argv 传递,不能拼成 shell 字符串。服务会校验参数非空且不含 NUL,invokingDir 还必须是不含 NUL 的绝对路径。在 Windows 上,provider 直接启动准确的已打包 entry,把进程树交给 DSH subprocess service 管理,插件不需要寻找 .cmd shim。

五、Desktop 专用插件的 Required Injection

如果一个插件只在 DSH Desktop 中有意义,可以把两个 service 声明为顶层 required dependency。Cordis 会等 provider 就绪后再挂载插件,并在依赖消失时卸载相关 effect。

import type { Context } from '@deepseek-ai/cordis'
import type {} from 'dsh-plugin-desktop/profile-service'
import type { DesktopPnpmHandle } from 'dsh-plugin-desktop/pnpm'

export const inject = ['desktopProfiles', 'desktopPnpm']

export function apply(ctx: Context): void {
  ctx.effect(() => {
    let active: DesktopPnpmHandle | undefined

    const removeAction = registerInstallAction(async (target) => {
      validatePackageTarget(target) // 按插件自己的信任策略校验来源
      active = ctx.desktopPnpm.runPlugin(['add', target], process.cwd())
      const outcome = await active.done
      if (outcome.exitCode !== 0) throw new Error('plugin install failed')
      active = undefined
    })

    return async () => {
      removeAction()
      active?.cancel()
      await active?.done.catch(() => {})
    }
  }, 'plugin-manager: user operation')
}

这里把注册 UI 动作、活跃 handle 和 disposer 放在同一个 effect 中非常重要:谁启动外部 I/O,谁负责在生命周期结束时取消并等待它退出。

六、跨普通 DSH 与 Desktop 的适配

一个同时支持 dsh web 和 Desktop 的插件,不能把 Desktop service 写入顶层 required inject,否则普通 DSH 永远无法满足依赖。正确方式是先探测 desktopProfiles,存在时再用嵌套 ctx.inject() 等待 desktopPnpm;不存在时挂载插件自己的普通 DSH adapter。

export const inject = ['webServer', 'loader']

export function apply(ctx: Context, config: { profile?: string }): void {
  const profiles = ctx.get('desktopProfiles')
  if (profiles === undefined) {
    // 普通 DSH 路径仍然是权威 fallback。
    mountManager(ctx, ordinaryDshAdapter(config.profile ?? 'web'))
    return
  }

  ctx.inject(['desktopPnpm'], (desktopCtx) => {
    mountManager(desktopCtx, {
      profile: profiles.current.name,
      profileDir: profiles.current.dir,
      runPlugin: (args, cwd, signal) =>
        desktopCtx.desktopPnpm.runPlugin(args, cwd, signal),
    })
  })
}
flowchart TD
    A["插件 Host apply()"] --> B{"desktopProfiles 存在?"}
    B -- "否" --> C["普通 DSH adapter"]
    B -- "是" --> D["读取 current snapshot"]
    D --> E["嵌套 inject desktopPnpm"]
    E --> F["Desktop adapter"]
    C --> G["挂载统一 Manager UI/Route"]
    F --> G

    classDef entry fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px;
    classDef choice fill:#f59e0b,color:#111827,stroke:#d97706,stroke-width:2px;
    classDef ordinary fill:#64748b,color:#fff,stroke:#334155,stroke-width:2px;
    classDef desktop fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px;
    classDef shared fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px;
    class A entry;
    class B choice;
    class C ordinary;
    class D,E,F desktop;
    class G shared;

图4 跨环境插件:共享业务界面,替换 Host adapter

注意,ctx.inject() 在嵌套 callback 内仍然是 required,只是它没有污染父插件的顶层依赖。当 desktopProfiles 已经存在却缺少 desktopPnpm 时,应把它视为 Desktop generation 失败,而不是偷偷退回 web Profile 并修改错误的目标。

七、把 Host Operation 投影到 Web 界面

desktopProfilesdesktopPnpm 都是 Host service,renderer 不能直接读取它们。带界面的插件应把“执行权限”留在 Host,把“交互状态”投影到普通 Web Client:用户在页面提交目标,Host route/RPC 完成鉴权与业务校验,创建 operation 后生成一个任务标识,再把有限状态返回给页面。前端只需要知道 queued、running、succeeded、failed、cancelled 等领域状态,不需要看到 Electron executable、Profile 物理路径或私有环境变量。

一个可靠的进度模型至少要区分快照和事件。页面首次打开时通过查询接口获得当前 operation 快照,随后订阅有序事件;断线重连后重新取快照,而不是假设每一行 stdout 都不会丢。Host 可以把 stdout/stderr 转换为最近若干行日志,但原始 stream 不应该无界保存在数组中,也不应该原样拼进 HTML。来自包管理器的文本仍是不可信输入,前端必须按普通文本渲染并避免注入。

interface PackageTaskSnapshot {
  id: string
  status: 'running' | 'succeeded' | 'failed' | 'cancelled'
  recentLines: readonly string[] // 只保留有界历史
  exitCode?: number | null
  signal?: NodeJS.Signals | null
}

// Host 拥有 handle,浏览器只看到稳定的领域快照。
function toSnapshot(task: ActiveTask): PackageTaskSnapshot {
  return {
    id: task.id,
    status: task.status,
    recentLines: task.log.tail(200),
    ...task.outcome,
  }
}

取消接口也应按任务标识工作,并验证请求者是否有权终止该任务;不能让 renderer 传入 PID,更不能把 DesktopPnpmHandle 序列化到客户端。插件 fiber dispose 时,Host disposer 仍是最后一道保障,即使页面崩溃、网络断开或用户直接切换 Profile,也会取消并等待自己拥有的 operation。这样设计后,普通浏览器模式与 Electron renderer 使用的是同一条应用协议,桌面环境并没有成为隐藏的特权捷径。

八、进度、取消与失败语义

desktopPnpm 每个 generation 同时只允许一个 package operation。这个限制避免多个 pnpm 同时修改同一 lockfile/Profile;界面遇到 busy error 时应该向用户说明已有任务,而不是循环重试制造请求风暴。

sequenceDiagram
    actor User as 用户
    participant UI as Web UI
    participant Host as 插件 Host Route
    participant Pnpm as desktopPnpm
    participant Sub as DSH Subprocess

    User->>UI: 点击安装
    UI->>Host: 发送已校验请求
    Host->>Pnpm: runPlugin(argv, cwd, signal)
    Pnpm->>Sub: spawn 精确 entry
    Sub-->>Host: stdout / stderr stream
    Host-->>UI: 有界进度事件
    alt 用户取消或 generation dispose
        Host->>Pnpm: cancel()
        Pnpm->>Sub: terminate process tree
    end
    Sub-->>Pnpm: exitCode + signal
    Pnpm-->>Host: done settle
    Host-->>UI: 成功 / 失败 / 已取消

图5 插件包操作生命周期:完成条件是整棵进程树退出

调用方至少要区分四种失败:参数校验与 busy 会同步抛错;spawn-level failure 会 reject done;普通命令失败会 resolve 为非零 exitCode;外部取消会通过 signal 体现。只写 await done 而不检查 outcome,会把正常返回的安装失败误判为成功。

输出流同样属于调用方责任。两个 stream 都要持续消费,避免子进程反压;若要把日志保存给前端,历史必须设置行数或字节上限,不能让长时间安装无限占用 Host 内存。service 没有内置超时,这是因为不同插件源和网络环境无法共享一个正确 deadline。

九、最小测试矩阵

仓库自己的 tests/fixtures/desktop-host-services-smoke-plugin 会作为 Profile 本地 bare package 被 Loader 激活,验证当前 Profile 身份以及 run()runPlugin() 是否存在,但不会真的修改依赖。插件项目还应根据自己的业务补充以下测试:

| 测试层 | 必测场景 | 预期结果 | | --- | --- | --- | | 纯单元测试 | adapter 参数、target 校验、outcome 映射 | 不启动真实包管理器 | | 普通 DSH Loader | Desktop service 不存在 | fallback 正常加载或按定义 pending | | Desktop Loader smoke | 两个 service 可见 | 读取的 name/dir 与激活 Profile 一致 | | 并发测试 | 两次安装或两个 Profile 目标 | busy/序列化行为明确,不互相覆盖 | | teardown 测试 | operation 中卸载插件 | 取消并等待完整进程树退出 | | 失败测试 | spawn reject、非零退出、signal | UI 得到不同且可行动的错误 | | 重启测试 | 安装成功后启动下一代 | 新 bundle 真正进入 Loader/client manifest |

仓库提供的相关验证命令是:

corepack yarn workspace dsh-plugin-desktop build
corepack yarn workspace dsh-plugin-desktop verify:profile

测试 fixture 位于 tests/,不会进入 npm files 或 Electron 安装包。对于自有插件,也应把 smoke fixture 与生产 archive 分开。

十、代码评审中常见的反例

第一类反例是从 process.argv 或配置项读取 Profile,并在读取失败时默认写入 web。在 Desktop 中,用户可能已经从托盘选择了完全不同的 Profile,这种 fallback 会把插件安装到错误目录。正确做法是:检测到 desktopProfiles 后只相信 current;Desktop provider 集合不完整时让 generation 失败,不要静默跨环境降级。

第二类反例是 spawn('pnpm', ['add', target])。它依赖调用机器的 PATH、shell shim、Node ABI 和 cwd,安装包环境下尤其容易在 Windows .cmd 解析或 native lifecycle script 处失败。即使裸命令偶尔可用,也绕过了单 operation gate、进程树回收和 DSH bundle reconcile。面向插件的代码应调用 desktopPnpm.runPlugin(),不要复制 Desktop 内部的启动环境。

第三类反例是在 module 顶层保存 ctx.desktopProfiles 或 handle,准备下一次重启继续复用。Cordis generation dispose 后,旧 service 会主动拒绝调用,旧 handle 也只属于已经关闭的子进程环境。缓存应该留在 effect 的局部作用域,新 generation 重新注入、重新读取和重新挂载。

第四类反例是把每一行安装日志广播给所有 Web 客户端,并永久保存在 session 中。这既可能泄露本地目录和 registry 信息,也会形成内存增长。进度协议应有访问控制、脱敏规则、容量限制和终态清理;详细诊断可以进入 Host 日志,但用户界面只展示对当前任务有用的有界内容。

第五类反例是安装命令 exit code 为零后立即在当前页面 import 新插件。当前 client manifest 由本代 Loader 组合决定,磁盘修改不会自动重写存活的模块图。正确的产品动作是提示用户重启或调用受支持的 Profile/应用重启流程,让新插件从下一 generation 的 Host audit 开始进入系统。

| 评审信号 | 潜在问题 | 推荐替代方案 | | --- | --- | --- | | process.argv 推断 Profile | 修改错误目标 | desktopProfiles.current | | spawn('pnpm') | PATH/ABI/进程树不可控 | desktopPnpm.runPlugin() | | 全局缓存 service | 跨 generation 使用失效引用 | effect 内持有,下一代重注入 | | 无限保存 stdout | 内存与信息泄露风险 | 有界 ring buffer + 权限控制 | | 当前页面动态 import 新包 | Loader/client manifest 不一致 | 成功后有序重启 |

十一、开发者 Checklist

  1. 只有明确的用户或管理员动作才能触发 package mutation。
  2. 当前 Profile 只从 desktopProfiles.current 读取,不做环境猜测。
  3. add/remove/update/install 使用 runPlugin(),低层场景才使用 run()
  4. invokingDir 传绝对路径,参数作为 argv,不拼 shell 文本。
  5. 同时消费 stdout/stderr,为日志历史设置容量上限。
  6. 提供 AbortSignal 或显式 cancel,并在 effect disposer 中等待结束。
  7. 同时处理同步异常、Promise rejection、exitCodesignal
  8. Profile 切换后丢弃旧 service reference,在下一代重新获取。
  9. renderer 通过普通 DSH route/RPC 通信,不添加私有 Electron bridge。
  10. 跨环境插件保留真实的普通 DSH fallback,不让 Desktop 成为硬依赖。

参考资料

总结

从插件作者的角度重新审视 anywhere-labs/deepseek-harness-desktop,我最认可的不是它额外提供了多少 Electron 能力,而是它只发布了第三方真正需要且能够长期维护的两个概念:当前 Profile 的权威身份,以及针对该 Profile 的受管包操作。desktopProfiles.current 把容易被 argv、URL 和环境变量混淆的运行目标固定为 generation snapshot,select() 则明确告诉开发者切换配置就是一次重启事务;desktopPnpm.runPlugin() 让插件继续服从上游 DSH 的 Profile 初始化、相对 source 锚定和 bundle reconcile 语义,而 stdout、stderr、timeout、cancel、exitCode 与 signal 又把进程控制责任完整交还给 consumer。更重要的是,这套 contract 没有把 BrowserWindow、托盘、私有 shim、Electron ABI 或 launcher 状态泄露给生态,因此宿主仍能在不破坏插件的前提下调整原生实现。实际开发中,我会把业务界面与执行 adapter 分开:普通 DSH 使用原有 CLI/Host 实现,Desktop 环境动态探测 desktopProfiles 后再嵌套注入 desktopPnpm;所有外部 I/O 都由一个 Cordis effect 持有,并在 dispose 时取消、等待和清理。页面与 Host 之间只交换经过校验的领域命令、任务标识和有界状态,不传 PID、绝对运行时入口或 service 对象;代码评审时则重点搜索环境猜测、裸 spawn、无限日志和跨代缓存,因为这些写法往往在开发机上安静无声,却会在真实安装包与重启场景中集中爆发。这样写出来的插件不只是在我的机器上“碰巧能装包”,而是能够解释每一个目标选择、每一次状态变化和每一种失败结果,也能够在网络断开、用户取消、Profile 切换和 Host teardown 时给出确定行为。对任何插件化桌面产品来说,这种可解释、可取消、可回收、可跨环境测试的接口,都比直接开放 Electron 私有对象更有生态价值,也更接近真正稳定的扩展平台。