DeepSeek Harness 文件预览全报「文件资源服务不可用」:protocolOf 的 host 解析修复

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH文件预览dsh-resourceprotocolOfWHATWG URLChromium 兼容性dsh-client-resources
每个文件预览都提示「文件资源服务不可用」,但宿主日志干净、客户端也不报错:protocolOf() 用 new URL().hostname 读 dsh-resource:// 这类非特殊 scheme 的协议名,老旧或定制 Chromium 不把它解析进 hostname,地址就被判成「没有 provider」。

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 或异常栈
换新内核浏览器开同一页面恢复正常依旧失败
控制台报错干净(该路径不抛异常)通常有报错
影响面所有预览入口(侧栏 / 聊天链接 / 交付物)全局一致视具体文件 / 接口而定

三步最小复现

  1. 用受影响的内核打开 Web GUI(http://127.0.0.1:3080,服务端版本无关,原帖在 0.1.5-rc.1 上验证)。要复现老内核可以用 npx @puppeteer/browsers install chrome@125.0.6422.60。
  2. 打开任意文本文件——侧边栏 Files 里点开 .md,或点聊天里的文件链接。
  3. 观察:预览区显示「文件资源服务不可用。」;DevTools Network 里零 /api/workspaceFiles 调用;控制台无报错。同一个服务端 / 会话 / 账号,在 Chromium ≥ 130 上渲染正常。

一行定性命令(在受影响页面 console 里执行)

js
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 特例」的对照实验:

js
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):

ts
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 变更的内核上成立。

失败链(一步不落地)

text
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 解析时间线影响。

js
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:

  1. Chrome 125.0.6422.60:修复前每个预览面都显示「文件资源服务不可用。」;修复后 text / code / markdown 预览正常渲染(workspaceFiles.stat + read 返回 200)。
  2. Edge 148:无回归(修复前后都正常)。

修法二:保留 new URL 快路径,只在拿不到 host 时兜底

思路:如果不想改动绝大多数内核上的既有行为,可以只加一条「引擎拿不到 host」的兜底支路——解析器正常时仍走原来的 parsed.hostname.toLowerCase(),只有 hostname === ''(或解析抛错)时才回退到字符串切分。对现有行为零影响,改动最小。

社区补丁形态(wenbin-wb,针对 protocolOf):

diff
 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()
+}

为什么这样改(补丁作者自述)

  1. 既有语义完全不变:解析器正常时仍走 parsed.hostname.toLowerCase();真正没有 host 的地址(dsh-resource:///no-host、dsh-resource:no-slash)仍返回 undefined。
  2. 只在 hostname === '' 时进入兜底,把「解析器拿不到 host」与「地址本来就没 host」明确区分开。
  3. 兜底按 opaque host 的合法字符集校验,并剥离 userinfo / 端口,保持与 URL 解析器「解析失败即拒绝」的语义一致(例如 dsh-resource://a:b/x、含空格的主机名仍判 undefined)。

配套单测(模拟「非特殊 scheme 的 hostname 恒为空」的内核)

ts
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 里有一行:

ts
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):

ts
// 反模式:非特殊 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 解析实验,而不是等日志。

  1. 别信服务端日志:宿主从未收到请求,日志里当然什么都没有(#6437、#6217)。
  2. 一行定性:在受影响页面跑 new URL('dsh-resource://file/x/y').hostname,受影响引擎返回 '',Chrome/Safari 返回 'file'(#6437)。
  3. 别只用大版本号判断:第三方 / 定制内核(Chromium 134、UA Chrome/122 的小米浏览器、鸿蒙 ArkWeb)都可能命中,而同一台机器的 Chrome 正常(#6437)。
  4. 看 provider 注册表而不是猜:受影响时 providers 里 "file" 是已注册的,但该地址的记录 protocol 为 null/undefined、status 恒为 none(#6437)。
  5. 刷新不可恢复:记录只在 provider 注册那一刻对 recordsOf(protocol) 回挂一次,protocol 为 undefined 的记录永远不在其中(#6437)。
  6. 客户端诊断能力本身是个坑:客户端 cordis logger 只注册了内存环形缓冲 exporter(1000 条),没有 console sink;实测浏览器控制台干净、日志缓冲是 {"total":0,"errs":0}——排查时完全没有线索,只能沿 React fiber 手工取出 resources 服务实例、直接枚举 providers 才能定位。开发构建下补一个 console exporter、或对「地址协议无 provider」发一条 debug 日志,能显著减少无效排查(#6437)。
  7. 临时规避与它的代价:
    1. 换用较新内核的 Chromium / Edge;或
    2. 给 …/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)。
  8. 上游状态:截至所引报告,protocolOf 与 pathOf 两处都未修复——0.1.7-alpha.2 的 dsh-client-resources/lib/client.js 里 protocolOf 与旧版逐字相同;0.1.7-rc.1 仍可复现;#7412 的版本扫描表可加一行「未修」(#6217)。
  9. 同一症状还有第三方旁证:#7514(provider 已注册却显示 none 的悖论)、#7430(Chromium 126 上仍可复现),与本根因族一致(#6437)。

排查这类问题时,用 DSH Plugin Hub 的已安装列表确认客户端插件全部 active(本问题里插件加载本就正常,正好用来排除「插件没加载」这一层),并在设置页检查更新、在通知中心查看安装 / 更新历史,先把「插件层」排除掉,再回到浏览器内核这一层。

DSH Plugin Hub · 确认更新

来源:Discussion #6217、Discussion #6437。

常见问题

DSH Web GUI 显示「文件资源服务不可用」,是后端文件服务挂了吗?

基本都不是。先做三个对照:同一次会话里宿主 RPC(workspaceFiles/list、stat、readAll)全部正常返回;DevTools Network 里 **一条 /api/workspaceFiles/* 请求都没有**;宿主/服务端日志没有任何相关记录。这三者同时成立,说明请求根本没发出去,断点在客户端的地址解析,而不是服务端文件服务。把同一个页面用较新内核的浏览器打开、预览同一个文件即可确认——恢复正常就基本锁定了这条根因。

为什么重启、升级 DSH 都没用?

因为这是客户端包 @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)。

哪些浏览器会中招?能按 Chromium 大版本号判断吗?

不能只看大版本号。已知 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)。

我按社区补丁改了 client.js,为什么升级后又坏了?

因为 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

来源