宿主平台是 DeepSeek Harness 面向浏览器的那一半:绑定回环 node:http server 的进程、在其上注册 API 路由、服务构建好的 Web shell dist、暴露已加载插件的只读视图,并让操作者选择工作目录。这里的一切都是 Cordis 插件——"宿主"不是单体二进制,而是 packages/host/* 下由叶级 cordis.yml 组装起来的组合。它与 client 平面(packages/client/* 下的浏览器 bundle)刻意形成对照:宿主包运行在 Node 中,拥有 socket、文件与进程;客户端包运行在浏览器中。
| 包 | 角色 | ctx key / 接缝 |
|---|---|---|
packages/host/webserver | node:http server;HTTP + upgrade + fallback 路由注册表 | ctx.webServer |
packages/host/frontend-static | 回退位上的 SPA dist 服务器;index-tap 注入 | 插件 frontend-static |
packages/host/plugin-inventory | Loader 条目的只读投影,供可信 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:httpServer(packages/host/webserver/src/index.ts)。它不提供文件服务、不了解任何 harness 概念;它是一个"路由注册载体"。命名路由、HTTP upgrade 路由与一个回退位由其他插件在启动时注册。 - 静态 dist 服务——
frontend-static认领回退位,以 SPA 语义服务构建好的@deepseek-ai/dsh-web-frontenddist。 - 插件库存——
plugin-inventory通过一个 Remote 调用反映 Cordis Loader 的当前条目,让浏览器内可信的设置面板可以渲染加载状态。 - 目录选择器——用于选择 workspace 的判别式能力接缝(
nativevsbrowse)。
Cordis 组装形态
随附 shell 的叶级组合是 packages/bundle/web-app/cordis.patch.yml。其 insert 列表的第 2 层把传输层立起来:
# ── 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 ?? 3080host 与 port 来自 webStartup provider(packages/bundle/web-app/src/startup.ts 中的 --host/--port/--trusted-host 标志族);默认端口 3080,仅回环。
webServer 服务
WebServer extends Service 并注册为 ctx.webServer。其 schema:
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 应答任何未认领的请求;在属主注册之前返回 404。upgrade 事件路径协商 WebSocket 式升级。
本修订下的真实路由
webserver 包自己不注册任何东西;由组合插件注册。随附 shell 中的两个具体载体:
| 路由 | 种类 | 属主 |
|---|---|---|
API_PATH = '/api' | prefix | packages/client/connection 的 node 半,经 ctx.webServer.register(...) |
/api/events.mux | upgrade | packages/client/connection(client-connection: /api route) |
/api/events.host | upgrade | packages/client/connection |
| 回退位 | fallback | frontend-static(在 web-app 的 apply 中认领) |
/api 前缀与两个 WebSocket 路径名在 packages/client/connection/src/api-path.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 只有一个 distIndex(dist/index.html 的绝对路径);在随附组合中它作为 workspace 知识在 web-app 内部解析(packages/bundle/web-app/src/index.ts 的 resolveDistIndex()),绝不由部署配置。
语义(按头注释锁定在 "step1"):
- 越出
distRoot的路径遍历 → 403; - 任何 miss → 以 200 返回
index.html(SPA 路由); - 未知扩展名 →
application/octet-stream;已知 MIME 表覆盖.js、.css、.svg、.json、.map、.webmanifest; - 非 GET/HEAD → 405;
- 每个 index 响应都经过 webserver 注册的 index taps(
ctx.webServer.applyIndexTaps(html))——启动清单与window.__DSH_BOOT__注入正是这样发生的。
web-app 挂载它:
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })并额外提供 webRuntime(由 bind + --trusted-host 推导的 LAN 可信主机)、注册 app:web-surface 与 harness:source 两个 prompt 段、设置 DSH_WEB_URL shell 变量,并在树 settle 后打印 URL 行。
插件库存
packages/host/plugin-inventory/src/index.ts 是活跃 ctx.loader 条目的只读 Remote 投影。PluginInventoryGateway extends TypertRemoteService 暴露单一 @Remote('list'),按 Loader 顺序快照非组条目:
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().kind 做 switch。
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.ts 的 resolveDirectoryPickerBackend),并把匹配对(后端 + 客户端界面)作为真实 Loader 条目挂载。决策顺序:
- bind host ≠
127.0.0.1→browse(全接口允许远程浏览器); - 设置了
SSH_CONNECTION/SSH_TTY→browse(无本地显示器); darwin/win32→native;linux→ 仅当存在DISPLAY/WAYLAND_DISPLAY且 PATH 上有选择器二进制(zenity/kdialog,用canExecute探测)才选native;否则browse。
任何歧义都解析为 browse,"它在任何地方都可用"。
native 后端
NativeDirectoryPicker 调用 pickNativeDirectory(packages/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 后端
BrowseDirectoryPicker(browse/src/index.ts)用 opendir 把目录流式读入按名称排序的有界窗口(maxEntries + 1,默认 1000,与 GitHub 网页 UI 一致),层级被截断时报告 truncated,跟随指向目录的符号链接,把点前缀条目标记为隐藏,并拒绝任何非全限定路径(绝不在宿主 cwd 下重定基——这是 Windows 上刻意的反盘符解析围栏)。createDirectory 写入一个校验过的单段。
两个后端都在服务生命周期内保持稳定的能力对象,符合接缝契约(DirectoryPicker.capability() 是抽象方法,只返回一次)。
app-boot:宿主服务如何启动
packages/boot/app-boot/src/index.ts 是 dsh 与 dsh-acp-demo 两个 bin 的共享启动胶水。其 boot() 流水线:
new Context(),把ctx.baseUrl设为配置目录;process.loadEnvFile('./.env')(opt-in)/ 经loadLayeredEnv的分层 env,拒绝 bootstrap-only 名称(含DSH_*/XDG_*前缀的拒绝清单——.env不得设置会改变进程/运行时/VCS/网络启动方式的变量);- 安装响亮失败守卫(
installFailLoud)——任何迟到的unhandledRejection变成一条带标签的 stderr 诊断 +exit(1),先给终端属主界面恢复 raw 模式的机会; ctx.plugin(Loader);然后mountRootInclude挂载cordis:include(叶配置)与cordis:group内置;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.ts、packages/host/frontend-static/src/index.ts、packages/host/plugin-inventory/src/index.ts、packages/host/directory-picker-auto/src/resolve.ts、packages/boot/app-boot/src/index.ts。 - 组合:
packages/bundle/web-app/cordis.patch.yml、packages/bundle/web-app/src/startup.ts。