ARTICLE · 1005637
DeepSeek-Harness 源码剖析(十·加):敲下 dsh web 之后、浏览器打开之前,发生了什么
摘要:dsh web 的启动链里没有显式初始化顺序表:webStartup 提供参数,webserver 把参数消费进 config 再监听,__DSH_BOOT__ 让浏览器插件图和 host 配置树同源。URL 那行日志打印之前,loader 先等整棵树 settle。三个准备动作加六个就绪台阶,逐个拆。
上一篇《源码剖析(十)》拆 web-ui,讲的是页面活着之后的事:一次 prompt 怎么在浏览器里跑起来。这篇倒回起点。命令敲下去,回车,终端安静一两秒,然后打出一行 dsh web: http://127.0.0.1:3000,浏览器自己弹开。这一两秒里发生了什么?更具体的问题:那行 URL 打印出来的瞬间,到底承诺了什么?
先把结论放这儿。整条启动链没有一张显式的初始化顺序表,谁先谁后完全从 inject 声明里涌现;URL 打印之前,dsh 等的是整棵插件树 settle,看到那行日志,意味着一切就绪。
这篇的名词密度不低,bundle、patch、fiber、boot 图。主线只有一条:跟着 npx @deepseek-ai/dsh web --port 3000 这条命令,从命令行一路走到浏览器里的 cordis。
一、准备段:三个动作把地基打好
前三个动作不产生任何「就绪」,它们负责把配置树搭起来。
第一个动作,命令行解析。apps/cli/src/bin.ts:27 调 parseDshArgs,web 是 commander 的硬编码子命令(args.ts:156-168),解析结果是一个 profile 调用。这里有个关键分工,args.ts 文件头注释写得很直白:launcher 只解析自己拥有的 flags(选哪个 profile、加哪些 patch),其余参数原样传进配置树,由树里的插件自己解析。--port 3000 在这一步只是个字符串,CLI 根本不理解它。
第二个动作,起 Cordis。profile-boot.ts:207 的 runProfile 最终调 boot()(packages/boot/app-boot/src/index.ts:757)。这段是 boot 的骨架,六步:
export async function boot(binName, absoluteConfigPath, patches, prepare) { const ctx = new Context() // :764 ctx.baseUrl = ... // :769 ctx.provide('dshHomePath', dshHomePath) // :770 await ctx.plugin(Loader) // :771 await prepare?.(ctx) // :772 环境快照 + cmdlineArgs await mountRootInclude(ctx, ...) // :774 按配置树挂载所有插件 await ctx.get('loader')?.await() // :782 等整棵树 settle assertEntriesActivated(...) // :784 审计:该激活的行都激活了}注意 prepare 的位置:它在任何配置树条目挂载之前运行(profile-boot.ts:252-258),把环境快照和命令行参数作为服务 provide 进去,保证第一行配置求值时就能读到 --port 3000。
第三个动作,配置树叠加。web profile 的 bundle 栈在 profile.ts:115:['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],基座包加 web 面补丁,叠加顺序是 bundle 层、profile 自己的 patch、home 层、--patch overlays(《源码剖析(一)》拆过这套 Profile 组装)。
base 层长什么样?packages/bundle/base/cordis.patch.yml 从 :15 起一个大 insert,我数了一遍,恰好 78 个 id:timer、hmr、llm、session、agent-loop、sandbox、各种 tool-\*。这一层是所有 profile 共享的 Agent 基座。
web-app 的 patch 有 445 行,三段式。先按 id 覆盖 base 行(system-prompt 换 Web 人设 :16、hmr 禁用 :22、tools 接 DSH_TOOLS_MODE 环境变量 :35),再 insert 自己的约 60 行,最后把 agent plane 的 23 行 base 工具全部 disabled: true,Web 面的工具改由 agent preset 动态组合。同一个基座,CLI 静态挂工具,Web 动态组工具,两副面孔并存。
insert 里最重要的两行是 webserver 和 web-runtime,这段是它们在 patch 里的原文(:121-126):
- 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注意 config 里两个 !!js 表达式:host 和 port 都从 webStartup 服务现取。这个服务还没就绪会怎样?行的 inject: [webStartup] 先拦住它,服务不就绪,行不激活。求值时机的问题,被一行 inject 声明解决了。
!!js 本身怎么求值?这段是求值器的全部实现(vendor/loader/src/config/utils.ts:4):
export const evaluate = new Function('ctx', 'expr', ` with (ctx) { return eval(expr) }`)就是以 loader ctx 为作用域的 eval,interpolate()(:12)递归替换整棵 YAML 树。所以 disabled: !!js process.platform === 'win32'(base patch :180,Windows 下不挂 bash 工具)和上面那句 port 取值,走的是同一条路:行激活、依赖就绪,才轮到求值。
地基打完了。命令行里的 --port 3000 还躺在一个叫 cmdlineArgs 的服务里,没人消费。第一个就绪台阶从它开始。
先把全景图放这儿,后面每一节都在填它的一段:

dsh web --port 3000
二、第一个台阶:webStartup,参数就绪
packages/bundle/web-app/src/startup.ts,整个插件 88 行,职责单一:把命令行 flags 变成一个服务。
它 inject cmdlineArgs(:17),provide webStartup(:20)。flags 有四个:--host、--port、--trusted-host、--no-open(:48-51)。校验带着明确的安全姿态,这段是 --host 0.0.0.0 的拒绝逻辑(:69-72):
if (options.host === '0.0.0.0') { program.error('error: --host 0.0.0.0 is intentionally not supported yet ' + 'for safety: it would expose remote code execution to the network; ' + 'use 127.0.0.1 instead')}注意拒绝的理由写进了报错文本:绑全网卡等于把远程代码执行暴露给局域网。想把服务给别人用,走 --trusted-host 显式授权,不走扩大监听面。
还有个安静的细节:--help 时这个插件什么都不 provide(:66-68 的注释写明这一语义)。两个服务都不存在,配置树里所有 inject 它的行都不会激活,没有任何端口被绑定。帮助信息跑完,进程干净退出。
三、第二个台阶:webServer,监听就绪
packages/host/webserver/src/index.ts,WebServer 类在 :73。它的 config 就来自第一节那句 !!js ctx.webStartup.port ?? 3080。
激活即监听。这段是 [Service.init] 里的绑定(:231-238):
await new Promise<void>((resolve, reject) => { this.server.once('error', reject) this.server.listen(this.config.port, this.config.host, () => { this.server.off('error', reject) this.server.on('error', (err) => { this.ctx.logger.error(err) }) this.listenedPort = (this.server.address() as AddressInfo).port resolve() })})注意两点。listen 的参数是 this.config.port,此刻它是定值,依赖已经在 config 求值时消费掉了,所以这个插件不需要等任何人,inject 列表里只有 webStartup 一项。绑不上端口,once('error', reject) 让激活本身失败,坏配置死在树里,拖不到运行时。
端口配 0 时由 OS 分配,实际端口记进 listenedPort(:86、:236),后面打印 URL 用的是这个值。它对外提供路由注册面:route、prefix、upgrade、fallback,都是单席位,先到先得。
HTTP 在听了。可浏览器要的页面和 API 还都没有。
四、第三个台阶:/api 网关,信任栅栏就位
webserver 只提供路由注册面,API 的活由三个插件接力完成。
api-gateway(web-app patch :105,包 dsh-host-apiproxy)是传输无关的 dispatch 面,所有会话、工具、设置操作的统一入口,它不关心请求从哪来。connection(packages/client/connection/src/index.ts)负责把网关接到 HTTP 上,这段是 /api 路由的核心几行(:156-159):
const apiProxy = ctx.get('apiProxy')if (apiProxy === undefined) return new Response('not found', { status: 404 })return toFetchHandler(apiProxy).fetch(request)注意 toFetchHandler 的来源(:7):它 import 自 dsh-host-apiproxy。网关不知道 HTTP 的存在,connection 把它包成一个 fetch handler,注册到 webserver 的 /api 席位上(:173),upgrade 路由给 SSE 和 WebSocket(:151)。
第三个角色是信任栅栏。api-request-trust.ts 做 Host 权威校验,授权名单来自 webRuntime.trustedHosts,第二节那个 --trusted-host flag 的最终消费点就在这里。防的是 DNS-rebinding 这类攻击:恶意页面让浏览器去请求你的 localhost。默认拒绝,显式授权才放行。
五、第四个台阶:webRuntime,静态文件就绪
packages/bundle/web-app/src/index.ts,name 在 :30,inject webServer(:39)。
前端产物怎么定位?这段是定位函数(resolveDistIndex,:163-171):
function resolveDistIndex(): string { const require = createRequire(import.meta.url) try { return require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') } catch { throw new Error('web-app: frontend dist not built; run pnpm run build ' + 'from the repository root first') }}注意它用 require.resolve 走包的 exports 解析,而不是配置项传路径。前端包 @deepseek-ai/dsh-web-frontend 就是仓库里的 apps/web(Vite 构建),dist 在哪是构建事实,不是用户配置。找到产物就 ctx.plugin(FrontendStatic, { distIndex })(:233),SPA fallback:未命中的路径返回 index.html。
它还 provide webRuntime 服务(:36),内容是 LAN 信任快照,就是第四节信任栅栏读的那份名单。副带两件小事:模型可见的 app:web-surface 提示段(:238),和给 bash 工具用的 DSH_WEB_URL 环境变量(:247),模型在会话里能查到自己这台 web 服务的地址。
六、第五个台阶:__DSH_BOOT__,插件图就绪
这一步是全链路设计感最重的一段。UI 插件是「双面 npm 包」:同一个 id,node 侧是配置树里的一行,浏览器侧是一个待加载的模块。
声明就在包的 package.json 里,比如 ui-theme(package.json:36-44):dsh.client: { platform: 'web', inject: [...], immediately: true }。web-app patch 的浏览器 roster 里,ui- 开头的插件我数了 31 个,theme、layout、conversation、sidebar、settings 全在。
谁把这两半缝起来?packages/client/modules/src/index.ts 的 ClientModuleRegistry(:282),static inject = ['webServer', 'loader'](:283)。它扫配置树里所有带 dsh.client 声明的行,组装 boot 图。这段是图里每一行的生成函数(graphRow,:167):
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry { return { id, url: `/plugins/${id}/client.js?rev=${rev}`, rev, ...(fields.inject !== undefined ? { inject: fields.inject } : {}), ...(fields.immediately ? { immediately: true } : {}), }}注意 url 里的 rev:内容的 sha1 前 12 位(:161),内容变 rev 变,浏览器缓存的旧 client.js 自动失效。然后两个动作:注册 /plugins prefix 路由(:340)serve 这些 client.js;tapIndex 把 window.__DSH_BOOT__ = 作为 head 里第一个