DeepSeek Harness 的 HTTP 服务器:dsh web 起来之后都开了哪些口
先把话说在前面:DeepSeek Harness 仓库的 README 自述该项目处于 developer preview(开发者预览)阶段,并用大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」(会有破坏兼容性的变更)。下面提到的每一个端口、路径、默认值都可能在后续版本里改掉,读的时候请当成「这一版快照里是这样」,而不是长期约定。
我们要回答的问题只有一个:dsh web 跑起来、浏览器指向本地那个地址之后,这个进程在 HTTP 上到底接受哪些路径,一个请求打进来又是按什么顺序被挑走的。
监听的那一行配置长什么样
承载 HTTP 的包是 packages/host/webserver,它的源码只有 src/index.ts 和 src/invariant.ts 两个文件。这个包的配置面窄得出奇,Config 只有两个字段:
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/** Listen port; zero requests an OS-assigned port. */
port: number
}
schema 那几行写得更死:host 是两个字面量的 union,port 是 z.natural().max(65535).required()。也就是说,这一层根本没有「绑到某块网卡」这种中间态,只有回环和全接口两个取值。
真正给它填值的地方在 packages/bundle/web-app/cordis.patch.yml 的 webserver 行:
- 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
ctx.webStartup 是 packages/bundle/web-app/src/startup.ts 提供的服务,它用 commander 解析 --host、--port、可重复的 --trusted-host 三个 flag(外加这个 app 自己的 --help)。为什么写成惰性表达式而不是字面量,apps/cli/reference/README.md 自述得很清楚:Loader 会等这个服务就绪再求值该行的 config,「A flag therefore beats the value written beside it」(因此 flag 胜过写在它旁边的值)。同一段还提醒:如果你在自己的 patch 里把整个 config 换成字面量,这个运行时读取也就一起没了。
顺手记两个细节。第一,--port 0 是合法的,schema 注释直接写明 zero requests an OS-assigned port(0 表示请操作系统分配一个空闲端口),而服务的 port getter 返回的是 this.listenedPort,即监听回调里从 server.address() 取到的实际值,所以 0 之后仍然能问出真实端口。第二,webserver 这个包从不打印任何东西——包头注释里那句「the URL line belongs to the shell」(URL 那一行属于外壳),你在终端看到的地址是 packages/bundle/web-app 那一行 printUrl: true 打出来的。
--host 0.0.0.0 这里有一处口径不一致
子系统文档 docs/subsystems/web-server.md 的 Config 段把 0.0.0.0 描述成 deliberate network exposure(刻意的网络暴露),看上去是个可选姿态。但 packages/bundle/web-app/src/startup.ts 里,命令行这一层直接把它拦掉了:
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')
}
apps/cli/reference/README.md 与实现一致:CLI 目前有意不支持 --host 0.0.0.0,会以用法错误退出。而仓库里 .agents/notes/implemented/feature/2026-07-22-web-bind-address.md 那篇实现笔记的「Consequences」段写的却是「a browser on another machine must opt in with dsh web --host 0.0.0.0」。两处说法不一致,以我们实读的 startup.ts 与 CLI 参考为准,至于哪一份写在前、为什么没同步,我们没有依据,不猜。
要提醒的是,承载层自己确实没有 TLS、没有认证、没有 origin 策略,docs/subsystems/web-server.md 的 Config 段原话就是 there is no TLS, auth, or origin policy,并接着说非回环绑定会把这台服务器暴露到那个网络里。所以这不是「有围栏所以安全」的问题,而是这一层根本没打算处理部署加固。
一个请求进来,按什么顺序被挑走
匹配逻辑就是 WebServer 的私有方法 match(pathname),短到可以整段读完:先查 exact 表,命中就返回;没命中就遍历 prefix 表,取路径最长的那条;两张表都没有就交给 fallback;连 fallback 都还没人认领时,回 404。前缀的判定是 pathname !== prefix && !pathname.startsWith(${prefix}/) 才跳过,也就是说前缀 p 只匹配 p 本身和 p/<任意后缀>,不会误伤 p-other 这种同前缀的兄弟路径。
注册顺序在这里完全不携带语义。重复的 (kind, path) 会直接抛错,错误文案是 webserver: duplicate ${route.kind} route "${route.path}";fallback 席位只有一个所有者,第二次 registerFallback 抛 webserver: fallback already registered。这是个有用的性质:你往组合里塞自己的插件时,路径撞车会在启动期炸掉,而不是变成运行期「谁先注册谁赢」的玄学。
shipped Web 组合里实际登记了哪几条
我们把整个 packages/ 下对 ctx.webServer 的注册调用点扫了一遍,非测试代码里的登记者是这些:
| 路径 | kind | 登记者 |
|---|---|---|
/api | prefix | packages/client/connection/src/index.ts |
/plugins | prefix | packages/client/modules/src/index.ts |
/plugins/events | exact | packages/client/hmr/src/index.ts |
| (fallback 席位) | — | packages/host/frontend-static/src/index.ts |
表里没列、但同样是注册调用点的还有一处:packages/client/connection/src/rpc-host.ts 里的私有 register(),它把一个通用 RPC channel 名当成 prefix 路径注册上去(kind: 'prefix',path: channel),通道名要过 /^\/[A-Za-z0-9._~-]+$/ 且不许等于 /api,否则抛 connection: invalid or reserved RPC channel。这条路子只有别的插件调 ctx.connection.rpc.handle(...) 才会产生路由;我们在非测试代码里没有找到这样的调用者,packages/api/gateway 用的是同一服务的 rpc.intercept('/api', …),走的是共享通道而不是新开一条路由。
upgrade 路由只有两条,都由 client-connection 注册,路径常量写在 packages/client/connection/src/api-path.ts:API_PATH 是 /api,两条 WebSocket 下行分别是 ${API_PATH}/events.mux 与 ${API_PATH}/events.host。upgrade 表只做精确匹配,没命中的连接直接 socket.destroy()。
/plugins 与 /plugins/events 正好演示了前面那条匹配顺序:前者是 prefix、后者是 exact,exact 表先查,所以 HMR 的事件流不会被 bundle 路由吞掉。而 client-modules 的 serveBundle 注释里也把这件事写清楚了——/plugins 下面除了 /<id>/client.js 与 /<id>/client.js.map,其余一律 404,包括 HMR 那一行不在组合里时打到的 /plugins/events。它宁可给一个「响亮的 404」,也不让请求滑到 SPA 回退去拿一张 HTML 页面,注释原话是 loud 404 beats a silent SPA-fallback HTML page。
fallback 席位的四条固定语义
认领席位的是 dsh-host-frontend-static,四条语义分两处:方法门在 apply() 注册的那个 fallback handler 里,其余三条在导出的 serveStatic 里。非 GET/HEAD 回 405(源码注释注明这是 fallback-only 语义,具名路由自己管方法);解析后的目标不是 dist 根本身、也不在 dist 根之下,回 403;命中根目录或 index 本身,走 index 渲染;readFile 抛错(ENOENT/EISDIR)时不是 404,而是以 HTTP 200 回退到 index.html——这是 SPA 路由要的行为。已知扩展名从一张 7 项的 MIME 表里取(.html、.js、.css、.svg、.json、.map、.webmanifest),表外的扩展名一律 application/octet-stream。
这里藏着一个对 Windows 用户友好的实现细节:越界判定用的是 target.startsWith(distRoot + sep),sep 而不是写死的 /。源码注释解释了原因——resolve() 在 Windows 上产出反斜杠路径,用 / 拼接会把每一条合法子路径都判成 traversal(目录穿越)。
distIndex 是 frontend-static 唯一的配置项,且不由部署方硬编码:packages/bundle/web-app/src/index.ts 里用 require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') 解析,解析不到就抛 web-app: frontend dist not built; run pnpm run build from the repository root first。
index 响应会被两处 tap 改写
tapIndex(transform) 登记一个纯 HTML→HTML 的转换,由 fallback 所有者在每一次 index 响应(/ 以及每一次 SPA 回退)上按注册顺序跑一遍。shipped Web 组合里我们找到两处 tap:client-modules 注入启动 manifest(就是浏览器侧读的 window.__DSH_BOOT__),client-ui-theme 注入启动主题。要注意「每一次回退都跑」这个语义——它意味着这段注入不是构建期塞进去的静态内容,而是随每次响应重新计算的。
/api 前面那道围栏,以及它自己声明的边界
/api 这条 prefix 路由的 handler 第一件事是 isTrustedApiRequest(req, trustedHosts),不过就回 403 forbidden。这个函数在 packages/client/connection/src/api-request-trust.ts,按顺序查三样:Host 头必须是回环或落在 trustedHosts 里;sec-fetch-site 若显式为 cross-site 直接拒;带了 Origin 时必须与 Host 归一化后严格相等,字面量 null 也拒。
更值得记住的是源码自己划的边界。文件头注释明写 network reachability and authentication stay out of scope(网络可达性与认证不在本模块范围内),并且这道围栏「is not an auth layer」(不是认证层)。packages/client/connection/src/index.ts 里那份 PRIVILEGED_METHODS 集合的注释说得更直白:trustedHosts 是一道 DNS-rebinding(DNS 重绑定)围栏,明确不是认证,所以整个配置面在真正的认证层出现之前都钉死在回环同源。落到代码上,就是集合里的方法名命中时用空信任列表再判一次。这张表我们逐行数过,一共 15 个:agentPreset.read、agentPreset.copy、agentPreset.openDocument、agentPreset.remove;host.pickDirectory、host.openPath;settings.describe、settings.openDocument、settings.update、settings.replace、settings.mutate;credentials.describe、credentials.set、credentials.unset;以及 llm.discoverModels。注释还专门点了没进这张表的两个:llm.providers 与 llm.models,理由文档自述是它们只带 provider id、显示名与模型列表,不带端点与密钥状态。注意 agentPreset.list 和「选一个 preset」也不在表里,注释给的理由是 session.create 本来就收 agentPreset 参数,只钉住切换那一步等于「在敞开的门旁边立一道栅栏」。
另外一处容易撞上的返回码:对 /api/events.mux 和 /api/events.host 发普通 GET,不会得到 404,而是 426 upgrade required,响应头里带 connection: Upgrade 与 upgrade: websocket。
出错和拆卸时它做什么
handle() 被包在一层 .catch 里,注释把动机写明了:一个畸形的百分号转义、或者客户端在请求体中途断开,如果放任 promise reject,就是一次未处理拒绝把进程带走。实际处置是记 warn 然后回 400;如果响应头已经发出去了,就 res.destroy()。upgrade 侧同理,handler 抛错或 socket 报错都是 warn + destroy。
dispose 那段有个容易忽略的点:server.close() 和 closeAllConnections() 必须配对,因为 handler 可能像 SSE 那样把响应一直挂着(/plugins/events 就是),这类连接不会自己结束,少了强制关闭拆卸就会挂住。而且 Node 的 closeAllConnections() 不包含已升级的 socket,所以这个服务用一个 upgradedSockets 集合自己跟踪、自己销毁,等 HTTP server 与这些 socket 都关掉才返回。
什么时候你会翻到这一层
三种情况。一是换端口或想让 OS 分配端口——你要找的是 webserver 行那两个 !!js 表达式,以及命令行优先于配置行这条规则。二是往组合里加自己的路由——先确认路径不与 /api、/plugins、/plugins/events 撞车,撞了会在启动期抛错;同时记住 fallback 席位已经被 frontend-static 占了,第二次认领同样抛错。三是排查「某个路径返回了一张 HTML 页面而不是我要的文件」——那基本就是走到了 fallback 的 200 回退分支。
反过来,有一种情况你完全不需要看这一层:apps/cli/reference/README.md 写明随附的 headless profile 不挂载 ApiProxy、Host、HTTP 服务器与浏览器客户端,成功运行不会打开监听端口。另外,Electron 也不走这台服务器——子系统文档和包头注释都写了,Electron 通过 file:// 加载已构建文件、经 IPC 桥接发 fetch。
最后一处口径差异留个记号:docs/subsystems/web-server.md 的「The service」那一段介绍了 register、tapIndex 和 port,没有提 registerUpgrade;registerUpgrade 只出现在该页由脚本生成的 Cordis API 段落和 packages/host/webserver/README.md 里。两边覆盖范围不同,查 upgrade 路由时别只读正文那一段。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。