Skip to content

[Bug] v0.19.0 原生右侧栏:工作区内图片 / PDF 预览与下载全部 400「is not an absolute path」(文件地址是相对路径,/sidebar/file 只收绝对路径) #618

Description

@longisland-icetea

提交前确认 / Pre-submit checklist

  • 我已搜索现有 issue,确认没有完全相同的条目 | I searched existing issues and confirmed there is no exact duplicate
  • 我已确认使用最新版本 dsh-better-sidebar | I confirmed I am on the latest dsh-better-sidebar version

运行环境 / Environment

DSH Web(浏览器访问)/ DSH Web (browser access)

DSH Web 版本 + 浏览器 / DSH Web version + Browser

DSH 0.1.5-rc.1(Linux / WSL2,dsh --profile web --port 3080)+ Chromium 系浏览器

插件版本 / Plugin version

dsh-better-sidebar 0.19.0(npm latest

类别 / Category

🐛 Bug(功能异常 / Something is broken)

描述 / Description

现象 / What happened

v0.19.0(原生右侧栏迁移后)工作区内的图片一律无法预览:右侧栏点开 .png / .jpg / .svg / .webp … 是空白 / 破图。同一根因还波及:

  • .pdf 预览(同一个 mediaUrl 通道):空白;
  • 二进制文件的「下载」链接(?download=1):点击同样 400;
  • .html渲染模式 iframe:400。

而 Markdown / 代码 / HTML 的文本预览完全正常——因为文本类 viewer 走 fsRead/sidebar/api/fs.read,图片 / PDF / 下载走 mediaUrl/sidebar/file

根因 / Root cause(已定位到行)

v0.19.0 起文件 tab 由文件地址播种,而地址对工作区内的文件用的是相对 session 根目录的相对路径

  1. src/client/resource-address.tsfileAddressFor(sessionId, cwd, path):路径在 cwd 之下时返回 sessionFileAddress(sessionId, normalized.slice(root.length + 1)),即 dsh-resource://file/session/<sid>/<相对路径>tests/resource-address.spec.ts 也钉住了这个行为)。
  2. src/client/native/index.tsfileParamsOf() 直接 return { path: address.path },于是 editor tab 的 tab.path相对路径(0.18 及以前 tab 里存的是绝对路径)。
  3. src/client/EditorHost.tsxconst mediaUrlOf = () => mediaUrl(scope, path),把相对路径原样交给 /sidebar/file
  4. src/index.ts/sidebar/file 路由是全插件唯一硬性要求绝对路径的文件路由:
    ensureWorkspacePath(cwd, raw, …)requireAbsolute(resolveSessionPath(cwd, target)),而 requireAbsolute() 对相对路径直接抛 fs-error
    对比 fs.read:它走 resolveGitPath(),相对路径会被解析到 session cwd(再按 git 根兜底),所以坏掉的只有 mediaUrl 这条通道。

.html 渲染模式是同一根因的另一种表现:/sidebar/html 的地址语法无法表达相对路径encodeHtmlUrlpath.split(/[\\/]+/).filter(s => s !== '') 丢掉前导 /decodeHtmlUrl 再统一补成绝对路径),于是 chart.html 被当成 /chart.html(根目录)解析 → 400/403。

复现步骤 / Steps to reproduce

  1. 启动 DSH Web 0.1.5-rc.1 + dsh-better-sidebar 0.19.0,进入任意会话(cwd 记为 /home/me),工作区里放一张 chart.png
  2. 从文件树(或聊天里的文件链接)点开该图片;
  3. 右侧栏空白 / 破图;DevTools Network 中 /sidebar/file?…&path=chart.png 返回 400
# A) 相对路径(编辑器实际发出的请求)→ 400,浏览器里就是破图
$ curl -s "http://127.0.0.1:3080/sidebar/file?sessionId=<sid>&path=chart.png&cwd=/home/me"
{"ok":false,"error":{"code":"fs-error","message":"\"chart.png\" is not an absolute path"}}

# B) 同一文件改成绝对路径 → 200,字节正确
$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
    "http://127.0.0.1:3080/sidebar/file?sessionId=<sid>&path=/home/me/chart.png&cwd=/home/me"
200 image/png

# C) 同一相对路径走 fs.read(文本预览的通道)→ 正常,证明只有媒体路由不接受相对路径
$ curl -s -X POST -H 'content-type: application/json' \
    -d '{"sessionId":"<sid>","path":"chart.png","cwd":"/home/me"}' \
    http://127.0.0.1:3080/sidebar/api/fs.read
{"ok":true,"value":{"kind":"text","content":"","truncated":false}}

# D) 相对路径的 HTML 渲染模式 → 被当成根目录路径
$ curl -s "http://127.0.0.1:3080/sidebar/html/<sid>/chart.html"
{"ok":false,"error":{"code":"fs-error","message":"cannot resolve target \"/chart.html\": ENOENT …"}}

期望行为 / Expected behavior

工作区内的图片 / PDF / 下载 / HTML 渲染与 Markdown、代码预览一样正常工作;工作区围栏(越界 403)保持不变。

建议修复 / Suggested fix(两处,互补;已在本地实现并验证)

  1. 宿主/sidebar/file 接受工作区相对路径——相对路径 join 到 session cwd,绝对路径原样透传,之后仍走原有 ensureWorkspacePath 围栏(与 fs.read 语义对齐;客户端 cwd 未知时也成立,第三方 mediaUrl viewer 一并受益)。
  2. 客户端fileUrlmediaUrl / downloadUrl)与 htmlUrl 统一用已有的 resolveSidebarPath(cwd, path) 解析后再编码——这正是 changes/DiffPane.tsx 现在对 html 预览做的处理;/sidebar/html 只能靠客户端解析,因此这一处是必需的。
展开完整源码 diff(src/index.ts + src/client/api.ts)
diff --git a/src/client/api.ts b/src/client/api.ts
index 2c40f36..b0d51c8 100644
--- a/src/client/api.ts
+++ b/src/client/api.ts
@@ -7,6 +7,7 @@
  * request). Failures surface as {@link SidebarApiError} with the wire code.
  */
 import { encodeHtmlUrl } from '../html-route.ts'
+import { resolveSidebarPath } from './produced-files.ts'
 import type { LastActivity } from '../subagent-activity.ts'
 import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
 import type { SidebarSessionEvent } from '../context-types.ts'
@@ -421,6 +422,26 @@ export const api = {
   openExternal,
 }
 
+/**
+ * Resolve a file-viewer path to the session-absolute spelling the media and
+ * HTML routes take. A native file address
+ * (`dsh-resource://file/session/<sid>/<path>`) spells a file inside the
+ * session workspace RELATIVE to that workspace's root, so since the
+ * native-sidebar migration the editor hands the viewers a workspace-relative
+ * path; the `/sidebar/file` route joins it onto the session cwd host-side
+ * (see `workspaceTarget`) and the `/sidebar/html` route cannot express a
+ * relative path at all (its encoder drops a leading `/`, its decoder reads the
+ * segments back as absolute), so resolve here — the same normalization
+ * `changes/DiffPane.tsx` already performs for its HTML preview and
+ * `markdown-images.ts` for markdown images. `resolveSidebarPath` returns an
+ * absolute path unchanged (including the outside-workspace spelling) and
+ * leaves a relative one alone when the session cwd is not known yet, which is
+ * the pre-existing behavior.
+ */
+function sessionAbsolute(cwd: string | undefined, path: string): string {
+  return resolveSidebarPath(cwd, path)
+}
+
 /** Absolute URL of the media route for one path (images only). */
 export function mediaUrl(scope: SessionScope, path: string): string {
   return fileUrl(scope, path, false)
@@ -434,7 +455,7 @@ export function downloadUrl(scope: SessionScope, path: string): string {
 
 /** Shared URL builder for the /sidebar/file route (media vs download). */
 function fileUrl(scope: SessionScope, path: string, download: boolean): string {
-  const params = new URLSearchParams({ sessionId: scope.sessionId, path })
+  const params = new URLSearchParams({ sessionId: scope.sessionId, path: sessionAbsolute(scope.cwd, path) })
   if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
   if (download) params.set('download', '1')
   return `/sidebar/file?${params.toString()}`
@@ -449,5 +470,5 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
  * client-side platform signal is needed.
  */
 export function htmlUrl(scope: SessionScope, path: string): string {
-  return encodeHtmlUrl(scope.sessionId, path)
+  return encodeHtmlUrl(scope.sessionId, sessionAbsolute(scope.cwd, path))
 }
diff --git a/src/index.ts b/src/index.ts
index 8eba7c3..3157f7d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -103,6 +103,27 @@ export function mediaTypeForPath(path: string): string {
   return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
 }
 
+/**
+ * Resolve a media/download route target inside the session namespace.
+ *
+ * DSH's file address (`dsh-resource://file/session/<sid>/<path>`) spells a
+ * file INSIDE the session workspace relative to that workspace's root, so
+ * since the native-sidebar migration the editor hands the image / PDF /
+ * download viewers a workspace-relative path. Joining it onto the session cwd
+ * here mirrors what `fs.read` has always done for the same client-supplied
+ * field; without it the media route was the only file route that demanded an
+ * absolute path and every image preview answered 400 `"<name>" is not an
+ * absolute path`. An absolute target — including the outside-workspace
+ * spelling an address keeps absolute — passes through untouched and stays
+ * bounded by the same workspace fence.
+ * @param cwd - the session's authoritative working directory.
+ * @param target - the client-supplied path (workspace-relative or absolute).
+ * @returns an absolute path for `ensureWorkspacePath`.
+ */
+export function workspaceTarget(cwd: string, target: string): string {
+  return isAbsolute(target) ? target : join(cwd, target)
+}
+
 /**
  * Resolve a session's authoritative working directory. The attached session
  * header wins; while the session is still hydrating from persistence (the
@@ -970,7 +991,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
         const raw = url.searchParams.get('path')
         if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
         const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
-        const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace))
+        // A native file address spells an in-workspace file relative to the
+        // session root, so the editor's image / PDF / download viewers send a
+        // workspace-relative path here (see workspaceTarget).
+        const path = await ensureWorkspacePath(cwd, workspaceTarget(cwd, raw), fenceEnabledOf(() => settingsFace))
         const info = await stat(path)
         if (!info.isFile() || info.size > resolved.mediaLimit) {
           throw new SidebarError('fs-error', 'not a file or too large', 400)

验证 / Verification

  • 新增两个上游风格用例(tests/media-relative-path.spec.ts 用假 ctx 挂载真实路由驱动 /sidebar/filetests/media-url-relative.spec.ts 直接驱动客户端 URL 构造器):在 v0.19.0 checkout 上 npx vitest run16 passed;越界 ../、工作区外绝对路径、指向工作区外的软链接仍全部 403。
  • 全量 vitest run(126 文件 / 1321 用例):37 个失败全部是依赖 node-pty 原生模块的环境性失败,git stash 去掉本补丁后失败数完全一致,无新增回归;tsc --noEmiteslint 干净。
  • 用补丁后源码 npx tsdown 重建 lib/,与手工落地的产物逐段比对:客户端 paths.ts / html-route.ts / produced-files.ts / api.ts 四段完全一致。

补充信息 / Additional context

  • 影响面:0.19.0 + DSH 0.1.5-rc.1 默认组合下,工作区内任何图片 / PDF 都预览不了(不是边界路径问题),属于迁移到原生右侧栏时引入的回归;0.18.1 不受影响(那时 tab 内是绝对路径)。
  • 临时 workaround:把请求里的 path 换成绝对路径即可正常取图(见上方 B)。
  • 需要的话我可以直接开 PR(补丁 + 两个用例都现成)。
  • 另注:客户端包由宿主以 cache-control: public, max-age=31536000, immutable 提供,修复后需硬刷新或重启宿主才生效。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions