DeepSeek Harness 文件预览全报「文件资源服务不可用」:protocolOf 的 host 解析修复
DSH 的 Web GUI 里,侧边栏 Files、聊天里的文件链接、交付物列表——每一个文件预览都显示「文件资源服务不可用。」;DevTools 里看不到任何 /api/workspaceFiles/* 请求,宿主侧日志干净,刷新也不恢复。 这不是后端文件服务坏了:宿主 RPC(workspaceFiles/list、stat、readAll)在同一次会话里全部正常返回。真正的断点在客户端——@deepseek-ai/dsh-client-resources 的 protocolOf() 用 new URL(address).hostname 去读 dsh-resource:// 这类非特殊 scheme 地址的协议名,而「非特殊 scheme 的 //authority 到底算不算 host」取决于浏览器对 2024 年 WHATWG URL 标准变更的实现(whatwg/url#731);在实现较老或自行裁剪过 URL 解析的 Chromium 内核上,hostname 是空串,于是地址被判成「没有 provider」,预览从未发起请求(#6217、#6437)。本文按「先分诊 → 根因 → 三条修法(纯字符串解析 / 保留快路径的兜底 / 解耦放大器)→ 排查注意事项」展开,每步都给可粘贴的代码与一行复现命令。
先分诊:这不是「后端文件服务坏了」
先确认三件事——RPC 是否正常、控制台是否有报错、换一个浏览器是否恢复。三者的组合直接指向客户端地址解析,而不是服务端。
| 判据 | 客户端地址解析不兼容 | 后端文件服务真的坏了 |
|---|---|---|
宿主 RPC workspaceFiles/list / stat / readAll | 全部 ok | 报错或超时 |
DevTools Network 里 /api/workspaceFiles/* | 一条都没有 | 有请求且失败 |
| 宿主 / 服务端日志 | 没有任何相关记录 | 有 4xx/5xx 或异常栈 |
| 换新内核浏览器开同一页面 | 恢复正常 | 依旧失败 |
| 控制台报错 | 干净(该路径不抛异常) | 通常有报错 |
| 影响面 | 所有预览入口(侧栏 / 聊天链接 / 交付物)全局一致 | 视具体文件 / 接口而定 |
三步最小复现
- 用受影响的内核打开 Web GUI(
http://127.0.0.1:3080,服务端版本无关,原帖在0.1.5-rc.1上验证)。要复现老内核可以用npx @puppeteer/browsers install chrome@125.0.6422.60。 - 打开任意文本文件——侧边栏 Files 里点开
.md,或点聊天里的文件链接。 - 观察:预览区显示「文件资源服务不可用。」;DevTools Network 里零
/api/workspaceFiles调用;控制台无报错。同一个服务端 / 会话 / 账号,在 Chromium ≥ 130 上渲染正常。
一行定性命令(在受影响页面 console 里执行)
const u = new URL('dsh-resource://file/session/s1/a.md');
console.log(u.protocol, JSON.stringify(u.hostname), JSON.stringify(u.pathname));
| 环境 | 输出 |
|---|---|
| 故障内核(Edge 129 等) | dsh-resource: "" "//file/session/s1/a.md" |
| 正常内核(Chromium ≥ 130 / Node) | dsh-resource: "file" "/session/s1/a.md" |
排除「只是 dsh-resource 特例」的对照实验:
new URL('foo://bar/baz').hostname // 故障内核 → "",pathname → "//bar/baz"
new URL('http://a/b').hostname // 故障内核 → "a"(特殊 scheme 正常)
即受影响内核对所有非特殊 scheme 都不把 //host 解析进 hostname,而特殊 scheme(http、ws、file)两者都正常——这一点把「浏览器对非特殊 scheme 的解析差异」和「provider 没注册」彻底区分开了。
根因:protocolOf() 把协议键建立在 WHATWG host 解析上
一句话:protocolOf() 需要一个「协议键」(file / chat / …)去查 provider 注册表,而它用 new URL(...).hostname 去取;对 dsh-resource: 这种非特殊 scheme,老内核不把 //authority 解析进 hostname,取到空串就返回 undefined,整条预览链就此断掉。
宿主端代码(packages/client/resources/src/client/resources.ts:56-68):
export function protocolOf(address: string): string | undefined {
let parsed: URL
try {
parsed = new URL(address)
} catch {
return undefined
}
if (parsed.protocol !== `${RESOURCE_SCHEME}:`) return undefined
// A non-special scheme's host is opaque to the URL parser and keeps its case.
return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase()
}
RESOURCE_SCHEME = 'dsh-resource'(:46),protocolOf 全仓只有一个调用点(:120)。注意第 67 行那句注释——它把「非特殊 scheme 的 host 对解析器不透明」当成了普遍成立的前提,而这只在已实现 2024 变更的内核上成立。
失败链(一步不落地)
new URL('dsh-resource://file/…').hostname === ""
→ protocolOf(address) === undefined
→ providers.get(undefined) === undefined
→ 记录被创建为 idle("none") (resources.ts:122)
→ 预览分支 meta.status === "none"
→ t("resourceUnavailable") (ui-sidebar-documentpreview/.../locales.ts:21、TextPreview.tsx:84/179)
→ 「文件资源服务不可用。」
宿主端从未收到请求,所以服务端日志看不出任何异常——这正是它难查的原因。三个预览入口(侧栏 Files、聊天文件链接、交付物)共用这一个注册表,因此故障是全局、刷新确定性、服务端不可见的。
版本分界:从「126」到「130」,再到「大版本号根本不可靠」
原帖认为 Chrome/Edge 126 起支持该解析;随后被更正为 130(chromestatus 5201116810182656、appui#1089 实测 v130+ 行为变化),因此 126–129 同样失败,#6437 的 Edge 129 实测恰是反例;更正者同时注明 whatwg/url#731 已 410 Gone,无法按号核对,但规范变更本身真实。
更要紧的是:后续数据点说明不能只按 Chromium 大版本号划分。
| 报告 / 环境 | new URL('dsh-resource://file/…').hostname | 预览 |
|---|---|---|
| Chrome 125.0.6422.60 | "" | 失败(打补丁后恢复) |
| Edge 129 | ""(pathname 为 "//file/…") | 失败 |
| Chromium 134(第三方内核,如百分浏览器) | "" | 失败 |
| 小米浏览器(UA Chrome/122) | "" | 失败 |
| 鸿蒙 ArkWeb 7.0.0.105(UA 自称 Chrome/144) | ""(同机 Chrome/Safari 返回 "file") | 失败 |
| Edge 148 / Chromium 153 / Node 26 | "file" | 正常 |
YOUKNOWWHOOO 的受控实验(同一服务端、同一会话、同一份客户端代码、同一套点击步骤,只换内核)把这条讲得更清楚:
| 观测项 | Chromium 134(第三方内核) | Chromium 153 |
|---|---|---|
new URL('dsh-resource://file/session/<id>/<path>') | hostname="",pathname="//file/session/…" | hostname="file",pathname="/session/…" |
| 该地址对应的资源记录 | protocol: null,status: "none" | protocol: "file",status: "live" |
| 预览面板 | 「文件资源服务不可用。」 | 正常显示文件内容 |
| 已注册的 provider | ["file"] | ["file"] |
| 客户端插件加载 | 全部 active | 全部 active |
注意倒数第二行:provider 明明已注册,坏的只是「地址 → 协议键」这一步。这也就解释了同症状线程 #7514 里「provider 已注册却显示 none」的悖论。鸿蒙 ArkWeb 那条还发现刷新不可恢复:注册表只在 provider 注册那一刻对 recordsOf(protocol) 回挂一次,protocol 为 undefined 的记录永远不在其中。
修法一:纯字符串解析,彻底不依赖 URL 解析器
思路:协议键本来就是「dsh-resource:// 前缀后的第一段」,直接对字符串切分即可,语义等价(空 authority 仍返回 undefined,非 dsh-resource: 仍返回 undefined),从此不受任何内核的 URL 解析时间线影响。
function protocolOf(address) {
const match = /^dsh-resource:\/\/([^/?#]+)/i.exec(address);
return match === null ? void 0 : match[1].toLowerCase();
}
一个必须注意的坑:大小写
原帖最早给的是 address.startsWith("dsh-resource://") 的写法——startsWith 大小写敏感,会打破既有单测 resources.client.spec.ts:98(它钉住 DSH-RESOURCE://File/... 也应得到 'file')。所以要么用上面带 i 标志的正则,要么先 toLowerCase() 再比较——不要直接照抄最初的 startsWith 片段。
Mide69 已按「大小写不敏感 + 去 userinfo」实现并推送分支:Mide69/deepseek-harness fix/resource-protocol-legacy-url-hosts。仓库所有权归他,正文给的是修法思路,合并与否以上游为准。
验证(原帖,本地)
把 0.1.5-rc.1 部署打上该片段后用无头浏览器驱动真实 GUI:
- Chrome 125.0.6422.60:修复前每个预览面都显示「文件资源服务不可用。」;修复后 text / code / markdown 预览正常渲染(
workspaceFiles.stat+read返回 200)。 - Edge 148:无回归(修复前后都正常)。
修法二:保留 new URL 快路径,只在拿不到 host 时兜底
思路:如果不想改动绝大多数内核上的既有行为,可以只加一条「引擎拿不到 host」的兜底支路——解析器正常时仍走原来的 parsed.hostname.toLowerCase(),只有 hostname === ''(或解析抛错)时才回退到字符串切分。对现有行为零影响,改动最小。
社区补丁形态(wenbin-wb,针对 protocolOf):
export function protocolOf(address: string): string | undefined {
- let parsed: URL
+ let parsed: URL | undefined
try {
parsed = new URL(address)
} catch {
- return undefined
+ parsed = undefined
}
- if (parsed.protocol !== `${RESOURCE_SCHEME}:`) return undefined
- // A non-special scheme's host is opaque to the URL parser and keeps its case.
- return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase()
+ if (parsed !== undefined) {
+ if (parsed.protocol !== `${RESOURCE_SCHEME}:`) return undefined
+ if (parsed.hostname !== '') return parsed.hostname.toLowerCase()
+ }
+ return hostOf(address)
+}
+
+/** An opaque host: anything else is a parse error for the URL parser too. */
+const OPAQUE_HOST = /^[A-Za-z0-9._~!$&'()*+,;=%-]+$/
+
+function hostOf(address: string): string | undefined {
+ const prefix = `${RESOURCE_SCHEME}://`
+ if (!address.toLowerCase().startsWith(prefix)) return undefined
+ const rest = address.slice(prefix.length)
+ const end = rest.search(/[/?#]/)
+ const authority = end === -1 ? rest : rest.slice(0, end)
+ const host = authority.slice(authority.lastIndexOf('@') + 1).replace(/:\d*$/, '')
+ if (host === '' || !OPAQUE_HOST.test(host)) return undefined
+ return host.toLowerCase()
+}
为什么这样改(补丁作者自述)
- 既有语义完全不变:解析器正常时仍走
parsed.hostname.toLowerCase();真正没有 host 的地址(dsh-resource:///no-host、dsh-resource:no-slash)仍返回undefined。 - 只在
hostname === ''时进入兜底,把「解析器拿不到 host」与「地址本来就没 host」明确区分开。 - 兜底按 opaque host 的合法字符集校验,并剥离 userinfo / 端口,保持与 URL 解析器「解析失败即拒绝」的语义一致(例如
dsh-resource://a:b/x、含空格的主机名仍判undefined)。
配套单测(模拟「非特殊 scheme 的 hostname 恒为空」的内核)
class QuirkURL extends URL {
get hostname(): string {
return String(this.protocol) === `${RESOURCE_SCHEME}:` ? '' : super.hostname
}
}
vi.stubGlobal('URL', QuirkURL)
onTestFinished(() => { vi.unstubAllGlobals() })
expect(protocolOf('dsh-resource://file/session/s1/home/ys/b.txt')).toBe('file')
expect(protocolOf('DSH-RESOURCE://File/session/s1/a')).toBe('file')
expect(protocolOf('dsh-resource://file:8080/x')).toBe('file')
expect(protocolOf('dsh-resource://user@file/x')).toBe('file')
expect(protocolOf('dsh-resource:///no-host')).toBeUndefined()
expect(protocolOf('dsh-resource:no-slash')).toBeUndefined()
expect(protocolOf('sidebar://guide')).toBeUndefined()
作者本地把补丁后的 protocolOf 逐行搬成 JS 跑:上游 spec 8 条既有断言 + 10 条「hostname 恒为空」内核模拟断言 + 13 条「两条路径结果一致」断言 = 32/32 通过;git apply --check -p1 干净通过(未跑仓库 vitest,新增 spec 需 CI 过一遍)。
同机另一条兜底实践
@wenbin_wb/dsh-bridge 2.10.12 已经用「特性探测 + 仅对 dsh-resource: 且原生 hostname 为空的实例补 hostname」上线,鸿蒙真机确认恢复。也就是说,如果你不能改客户端包,桥接层 / 插件层也能兜——这条经验对写插件的读者尤其有用。
修法三与同族第二处:解耦放大器、pathOf 的同类解析
修法三:解耦放大器——内容读取不该绑定元数据可用性
思路:即使 protocolOf 修好了,也建议顺手拆掉一个放大器。 TextPreview 里有一行:
const canRead = meta.status !== 'none'
它把内容读取(走 remote.workspaceFiles,本就带类型化失败与重试)绑在了元数据可用性上。于是「地址解析没认出协议」这种小失误,被放大成「整个预览彻底不可用」。把两者解耦,这个 bug 就降级为「自动刷新关闭」,内容仍能读出来——一行改动,且是有价值的纵深防御(#6217)。
报告者的实测也支持这一点:他在受影响内核里先上这一行解耦,症状就消失了,根因补丁是之后才打的;他建议即便 protocolOf 修好也保留这层解耦。
同族第二处:pathOf 用 new URL().pathname 解析同类地址
同一个「用 URL 解析器切 dsh-resource:// 地址」的反模式还出现在右侧栏 tab 注册表里(ui-sidebar-right 的 tab-registry.ts:189-191):
// 反模式:非特殊 scheme 下 authority 会被算进 pathname
new URL(address).pathname
new URL(address).pathname 只有当引擎把 dsh-resource:// 的 authority 认成 host 时才排除 authority;在受影响内核上,"/session/…" 变成 "//file/session/…",于是只匹配路径的 glob(*.png、*.md 之类)永远不会命中 dsh-resource:// 地址——这就成了「预览打不开」之外的第二类静默故障。
Mide69 已把它一起修掉并推在同一分支上,且泛化到任意 scheme://authority 地址(pathOf 自身的契约覆盖的不止 dsh-resource:)。如果你的排障里出现过「某些按扩展名匹配的功能在老内核上莫名不生效」,值得顺手核一下这里。
排查注意事项
第一原则:这套症状的「静默」是设计出来的——失败路径不抛异常、宿主收不到请求、客户端日志也没有 console sink,所以你必须在浏览器里主动做那行 URL 解析实验,而不是等日志。
- 别信服务端日志:宿主从未收到请求,日志里当然什么都没有(#6437、#6217)。
- 一行定性:在受影响页面跑
new URL('dsh-resource://file/x/y').hostname,受影响引擎返回'',Chrome/Safari 返回'file'(#6437)。 - 别只用大版本号判断:第三方 / 定制内核(Chromium 134、UA Chrome/122 的小米浏览器、鸿蒙 ArkWeb)都可能命中,而同一台机器的 Chrome 正常(#6437)。
- 看 provider 注册表而不是猜:受影响时
providers里"file"是已注册的,但该地址的记录protocol为null/undefined、status恒为none(#6437)。 - 刷新不可恢复:记录只在 provider 注册那一刻对
recordsOf(protocol)回挂一次,protocol为undefined的记录永远不在其中(#6437)。 - 客户端诊断能力本身是个坑:客户端 cordis logger 只注册了内存环形缓冲 exporter(1000 条),没有 console sink;实测浏览器控制台干净、日志缓冲是
{"total":0,"errs":0}——排查时完全没有线索,只能沿 React fiber 手工取出resources服务实例、直接枚举providers才能定位。开发构建下补一个 console exporter、或对「地址协议无 provider」发一条 debug 日志,能显著减少无效排查(#6437)。 - 临时规避与它的代价:
- 换用较新内核的 Chromium / Edge;或
- 给
…/dsh-client-resources/lib/client.js打上修法一的替换。注意 DSH 常由npx @deepseek-ai/dsh启动,代码在%LOCALAPPDATA%\npm-cache\_npx\<hash>\node_modules,而~/.dsh/profiles/node_modules/@deepseek-ai/*是指向它的 junction——每次更新 DSH 都会覆盖这份缓存,补丁随之失效(#6437)。
- 上游状态:截至所引报告,
protocolOf与pathOf两处都未修复——0.1.7-alpha.2的dsh-client-resources/lib/client.js里protocolOf与旧版逐字相同;0.1.7-rc.1仍可复现;#7412的版本扫描表可加一行「未修」(#6217)。 - 同一症状还有第三方旁证:
#7514(provider 已注册却显示none的悖论)、#7430(Chromium 126 上仍可复现),与本根因族一致(#6437)。
排查这类问题时,用 DSH Plugin Hub 的已安装列表确认客户端插件全部 active(本问题里插件加载本就正常,正好用来排除「插件没加载」这一层),并在设置页检查更新、在通知中心查看安装 / 更新历史,先把「插件层」排除掉,再回到浏览器内核这一层。

常见问题
基本都不是。先做三个对照:同一次会话里宿主 RPC(workspaceFiles/list、stat、readAll)全部正常返回;DevTools Network 里 **一条 /api/workspaceFiles/* 请求都没有**;宿主/服务端日志没有任何相关记录。这三者同时成立,说明请求根本没发出去,断点在客户端的地址解析,而不是服务端文件服务。把同一个页面用较新内核的浏览器打开、预览同一个文件即可确认——恢复正常就基本锁定了这条根因。
因为这是客户端包 @deepseek-ai/dsh-client-resources 里的地址解析逻辑,不是运行期状态。0.1.7-alpha.2 的 dsh-client-resources/lib/client.js 里 protocolOf 与旧版**逐字相同**,0.1.7-rc.1 仍可复现;#7412 的版本扫描表可以加一行「未修」。升级不会改变你所用内核的 URL 行为,所以症状不变。
在受影响的页面里跑一行:new URL('dsh-resource://file/x/y').hostname。受影响内核返回 ''(空串,pathname 是 //file/x/y),Chrome/Safari/较新 Chromium 返回 'file'。同一台机器的不同内核给出不同结果,正是「非特殊 scheme 的 authority 解析差异」的指纹(Discussion #6437)。
不能只看大版本号。已知 Chrome 125、Edge 129、Chromium 134 的第三方内核(如百分浏览器)、小米浏览器(UA Chrome/122)、鸿蒙 ArkWeb 7.0.0.105(UA 自称 Chrome/144)都命中过,而 Edge 148 / Chromium 153 / Node 26 正常。原帖说 Chromium 126 起支持,随后被更正为 130,但后续实测表明**定制或第三方内核可以偏离这条时间线**,所以判断依据应是那行 hostname 探测,而不是版本号(Discussion #6437)。
因为 DSH 通常由 npx @deepseek-ai/dsh 启动,客户端代码在 %LOCALAPPDATA%\npm-cache\_npx\<hash>\node_modules,而 ~/.dsh/profiles/node_modules/@deepseek-ai/* 是指向它的 junction。**每次更新 DSH 都会覆盖这份缓存,补丁随之失效**,需要重新打一次。想一劳永逸,应推动上游合并修法一/修法二,或在不能改客户端包时于桥接/插件层做特性探测兜底(Discussion #6437)。
相关术语
- 非特殊 scheme 的 authority 解析(whatwg/url#731)
- URL 标准在 2024 年的变更「Allow non-special schemes to have hosts」,让 `dsh-resource:`、`foo:` 这类非特殊 scheme 的 `//authority` 也会被解析进 `hostname`。该变更在不同引擎/内核上的落地时间并不一致:老内核会把 authority 留在 `pathname`(`//file/…`)、`hostname` 为空。原帖引用的 whatwg/url#731 现已 410 Gone,无法按号核对,但规范变更本身真实存在。— https://github.com/deepseek-ai/deepseek-harness/discussions/6217
- dsh-resource: 与协议键(protocol key)
- 资源地址的统一协议,`RESOURCE_SCHEME = 'dsh-resource'`,地址形如 `dsh-resource://file/session/<id>/<path>`。其中 `file`、`chat` 这一段是「协议键」,`ResourceRegistry` 用它去 `providers.get(...)` 查对应的 provider。`protocolOf()` 的唯一职责就是从地址里取出这个键。— https://github.com/deepseek-ai/deepseek-harness/discussions/6437
- ResourceRegistry 的 idle("none")
- 当 `protocolOf()` 返回 `undefined` 时,`providers.get(undefined)` 也拿不到 provider,于是这条地址对应的资源记录被创建成 `idle('none')`(`resources.ts:122`)。预览分支见 `meta.status === 'none'` 就渲染 `t('resourceUnavailable')`(`ui-sidebar-documentpreview/.../locales.ts:21`、`TextPreview.tsx:84/179`),整个过程不发起任何请求、也不抛异常,因此完全静默。— https://github.com/deepseek-ai/deepseek-harness/discussions/6437
- 内容读取与元数据可用性耦合
- `TextPreview` 里 `const canRead = meta.status !== 'none'` 把「内容读取」(走 `remote.workspaceFiles`,自带类型化失败与重试)绑在了「元数据可用性」上。结果是一次地址解析失误被放大成「整个预览彻底不可用」。把两者解耦后,同样的失误只降级为「自动刷新关闭」,内容仍能读出——一行改动,是很有价值的纵深防御。— https://github.com/deepseek-ai/deepseek-harness/discussions/6217
来源
- #6217 — Bug: Web GUI file preview shows "file resource service unavailable" on Chromium <= 125 — protocolOf() relies on WHATWG URL host parsing for a non-special scheme· deepseek-ai(GitHub Discussions)
- #6437 — [Bug] dsh-client-resources 的 protocolOf 依赖 new URL().hostname,Edge 129 下文件预览显示“文件资源服务不可用”· deepseek-ai(GitHub Discussions)