Skip to content

宿主平台是 DeepSeek Harness 面向浏览器的那一半:绑定回环 node:http server 的进程、在其上注册 API 路由、服务构建好的 Web shell dist、暴露已加载插件的只读视图,并让操作者选择工作目录。这里的一切都是 Cordis 插件——"宿主"不是单体二进制,而是 packages/host/* 下由叶级 cordis.yml 组装起来的组合。它与 client 平面(packages/client/* 下的浏览器 bundle)刻意形成对照:宿主包运行在 Node 中,拥有 socket、文件与进程;客户端包运行在浏览器中。

角色ctx key / 接缝
packages/host/webservernode:http server;HTTP + upgrade + fallback 路由注册表ctx.webServer
packages/host/frontend-static回退位上的 SPA dist 服务器;index-tap 注入插件 frontend-static
packages/host/plugin-inventoryLoader 条目的只读投影,供可信 RPC 使用ctx.pluginInventory
packages/host/directory-picker抽象 ctx.directoryPicker 能力接缝ctx.directoryPicker
packages/host/directory-picker-auto启动期解析器,挂载 native 或 browse插件 directory-picker-auto
packages/host/directory-picker-native原生 OS 选择器后端(native注册 ctx.directoryPicker
packages/host/directory-picker-browse应用内列目录/建目录后端(browse注册 ctx.directoryPicker
packages/boot/app-boot共享启动胶水:env、config、Loader 驱动boot()、profiles
packages/bundle/web-app随附的 Web 组合(拥有 dist 解析)插件 web-app

宿主拥有什么

宿主只拥有那些确实需要带网络/文件系统访问的 Node 进程的关注点:

  • 服务器——单一 node:http Serverpackages/host/webserver/src/index.ts)。它提供文件服务、不了解任何 harness 概念;它是一个"路由注册载体"。命名路由、HTTP upgrade 路由与一个回退位由其他插件在启动时注册。
  • 静态 dist 服务——frontend-static 认领回退位,以 SPA 语义服务构建好的 @deepseek-ai/dsh-web-frontend dist。
  • 插件库存——plugin-inventory 通过一个 Remote 调用反映 Cordis Loader 的当前条目,让浏览器内可信的设置面板可以渲染加载状态。
  • 目录选择器——用于选择 workspace 的判别式能力接缝(native vs browse)。

Cordis 组装形态

随附 shell 的叶级组合是 packages/bundle/web-app/cordis.patch.yml。其 insert 列表的第 2 层把传输层立起来:

yaml
# ── layer 2: transport/service ──────────────────────────────────────────────
- id: webserver
  name: '@deepseek-ai/dsh-host-webserver'
  inject: [webStartup]
  config:
    host: !!js ctx.webStartup.host ?? '127.0.0.1'
    port: !!js ctx.webStartup.port ?? 3080

hostport 来自 webStartup provider(packages/bundle/web-app/src/startup.ts 中的 --host/--port/--trusted-host 标志族);默认端口 3080,仅回环。

webServer 服务

WebServer extends Service 并注册为 ctx.webServer。其 schema:

ts
export type WebRouteKind = 'exact' | 'prefix'
export interface WebRoute {
  kind: WebRouteKind
  path: string                       // 绝对 pathname,无尾斜杠
  handler: (req, res) => void | Promise<void>  // 可保持响应打开(SSE)
}
export interface Config { host: '127.0.0.1' | '0.0.0.0'; port: number }

公开面:register(route)registerUpgrade(route)registerFallback(handler)tapIndex(transform),外加只读的 port/host getter。重复路由、重复 upgrade 或第二个 fallback 都会抛出——每个都是组合级契约。匹配规则:先精确表,再对前缀表最长前缀胜出。fallback 应答任何未认领的请求;在属主注册之前返回 404upgrade 事件路径协商 WebSocket 式升级。

本修订下的真实路由

webserver 包自己不注册任何东西;由组合插件注册。随附 shell 中的两个具体载体:

路由种类属主
API_PATH = '/api'prefixpackages/client/connection 的 node 半,经 ctx.webServer.register(...)
/api/events.muxupgradepackages/client/connectionclient-connection: /api route
/api/events.hostupgradepackages/client/connection
回退位fallbackfrontend-static(在 web-appapply 中认领)

/api 前缀与两个 WebSocket 路径名在 packages/client/connection/src/api-path.ts 中定义一次:

ts
export const API_PATH = '/api'
export const MUX_EVENTS_PATH  = `${API_PATH}/events.mux`
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`

frontend-static:服务 dist

packages/host/frontend-static/src/index.ts回退位服务构建好的 SPA。其 Config 只有一个 distIndexdist/index.html 的绝对路径);在随附组合中它作为 workspace 知识在 web-app 内部解析(packages/bundle/web-app/src/index.tsresolveDistIndex()),绝不由部署配置。

语义(按头注释锁定在 "step1"):

  • 越出 distRoot 的路径遍历 → 403
  • 任何 miss → 以 200 返回 index.html(SPA 路由);
  • 未知扩展名 → application/octet-stream;已知 MIME 表覆盖 .js.css.svg.json.map.webmanifest
  • 非 GET/HEAD → 405
  • 每个 index 响应都经过 webserver 注册的 index tapsctx.webServer.applyIndexTaps(html))——启动清单与 window.__DSH_BOOT__ 注入正是这样发生的。

web-app 挂载它:

ts
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })

并额外提供 webRuntime(由 bind + --trusted-host 推导的 LAN 可信主机)、注册 app:web-surfaceharness:source 两个 prompt 段、设置 DSH_WEB_URL shell 变量,并在树 settle 后打印 URL 行。

插件库存

packages/host/plugin-inventory/src/index.ts 是活跃 ctx.loader 条目的只读 Remote 投影PluginInventoryGateway extends TypertRemoteService 暴露单一 @Remote('list'),按 Loader 顺序快照非组条目:

ts
interface PluginInventoryEntry {
  entryId: PluginEntryId
  moduleName: string    // Loader 条目 import 的确切模块说明符
  enabled: boolean      // 有效启用状态,含被禁用的祖先组
  fiberPhase: PluginFiberPhase  // 'pending'|'loading'|'active'|'failed'|'unloading'|null
}

它每次调用直接读 Loader(this.ctx.loader.entries())——没有第二层缓存,因为 Loader 事件已经维护了 fiber 生命周期。它的 web 界面是 @deepseek-ai/dsh-client-ui-settings-plugin-inventory(插件清单标签页);设置里的「插件」面板本身是 @deepseek-ai/dsh-client-ui-settings-plugins

目录选择器

ctx.directoryPicker判别式能力:后端暴露一种交互形态,消费者按 capability().kindswitch

ts
interface DirectoryPickerCapabilities {
  native: { kind: 'native'; pick(signal: AbortSignal): Promise<string | null> }
  browse: { kind: 'browse'; list(path?, signal?): Promise<DirectoryListing>;
            createDirectory(path, name): Promise<string> }
}

为什么有两种?native 后端在宿主的显示器上打开一个 OS 选择器——对远程客户端毫无用处。browse 后端用 Node 标准库提供单层列表 + 子目录创建,因此对远程浏览器同样可用。

auto 如何选择

packages/host/directory-picker-auto启动时只采样一次宿主(resolve.tsresolveDirectoryPickerBackend),并把匹配对(后端 + 客户端界面)作为真实 Loader 条目挂载。决策顺序:

  1. bind host ≠ 127.0.0.1browse(全接口允许远程浏览器);
  2. 设置了 SSH_CONNECTION/SSH_TTYbrowse(无本地显示器);
  3. darwin/win32native
  4. linux → 仅当存在 DISPLAY/WAYLAND_DISPLAY PATH 上有选择器二进制(zenity/kdialog,用 canExecute 探测)才选 native;否则 browse

任何歧义都解析为 browse,"它在任何地方都可用"。

native 后端

NativeDirectoryPicker 调用 pickNativeDirectorypackages/host/directory-picker-native/src/native-picker.ts)。按平台:macOS 运行 osascript choose folder;Linux 运行 zenity --file-selection --directory 再回退 kdialog --getexistingdirectory;Windows 生成一个子进程运行 koffi 支持的 IFileOpenDialog COM 会话(win32-dialog*.ts)。Abort(signal)终止 OS 命令;用户取消返回 null

browse 后端

BrowseDirectoryPickerbrowse/src/index.ts)用 opendir 把目录流式读入按名称排序的有界窗口maxEntries + 1,默认 1000,与 GitHub 网页 UI 一致),层级被截断时报告 truncated,跟随指向目录的符号链接,把点前缀条目标记为隐藏,并拒绝任何非全限定路径(绝不在宿主 cwd 下重定基——这是 Windows 上刻意的反盘符解析围栏)。createDirectory 写入一个校验过的单段。

两个后端都在服务生命周期内保持稳定的能力对象,符合接缝契约(DirectoryPicker.capability() 是抽象方法,只返回一次)。

app-boot:宿主服务如何启动

packages/boot/app-boot/src/index.tsdshdsh-acp-demo 两个 bin 的共享启动胶水。其 boot() 流水线:

  1. new Context(),把 ctx.baseUrl 设为配置目录;
  2. process.loadEnvFile('./.env')(opt-in)/ 经 loadLayeredEnv 的分层 env,拒绝 bootstrap-only 名称(含 DSH_*/XDG_* 前缀的拒绝清单——.env 不得设置会改变进程/运行时/VCS/网络启动方式的变量);
  3. 安装响亮失败守卫installFailLoud)——任何迟到的 unhandledRejection 变成一条带标签的 stderr 诊断 + exit(1),先给终端属主界面恢复 raw 模式的机会;
  4. ctx.plugin(Loader);然后 mountRootInclude 挂载 cordis:include(叶配置)与 cordis:group 内置;
  5. await ctx.get('loader')?.await();用 assertEntriesLoaded / assertEntriesActivated 审计;返回已 settle 的根 Context

resolveConfigPath重放快照模式下把 cordis.yml 基名换成 cordis.snapshot.yml。profile 启动器(profile.ts)先从安装再从 profile 目录解析 bundle,并维护扁平的 $DSH_HOME/profiles/node_modules 符号链接回退,让树外插件共享安装的单一 Cordis 实例。

端口与绑定配置

键 / 标志含义默认
webServer.config.host'127.0.0.1' | '0.0.0.0''127.0.0.1'
webServer.config.port监听端口;0 = OS 分配3080
--host <host> / --port <port>@deepseek-ai/dsh-web-app/startup 解析的 CLI 标志回环 / 3080
--trusted-host <authority...>/api 浏览器信任围栏额外接受的 authority

注意 packages/bundle/web-app/src/startup.ts 中的安全守卫:--host 0.0.0.0 被直接拒绝("向网络暴露远程代码执行")。默认 Harness home 是 ~/.dsh(可用 DSH_HOME 覆盖)——见 packages/util/home-paths

延伸阅读

  • 存储与持久化——宿主拥有的持久状态落在哪里(JSON vs SQLite、sessions、投影)。
  • 身份、附件与反馈——宿主组合的匿名 id、附件与消息反馈服务。
  • 扩展系统(Cordis)——每个宿主行依赖的 Cordis 插件模型。
  • Monorepo 结构解剖——host 与 client 包拆分。
  • 源码:packages/host/webserver/src/index.tspackages/host/frontend-static/src/index.tspackages/host/plugin-inventory/src/index.tspackages/host/directory-picker-auto/src/resolve.tspackages/boot/app-boot/src/index.ts
  • 组合:packages/bundle/web-app/cordis.patch.ymlpackages/bundle/web-app/src/startup.ts