提交前确认 / Pre-submit checklist
运行环境 / 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 根目录的相对路径:
src/client/resource-address.ts — fileAddressFor(sessionId, cwd, path):路径在 cwd 之下时返回 sessionFileAddress(sessionId, normalized.slice(root.length + 1)),即 dsh-resource://file/session/<sid>/<相对路径>(tests/resource-address.spec.ts 也钉住了这个行为)。
src/client/native/index.ts — fileParamsOf() 直接 return { path: address.path },于是 editor tab 的 tab.path 是相对路径(0.18 及以前 tab 里存的是绝对路径)。
src/client/EditorHost.tsx — const mediaUrlOf = () => mediaUrl(scope, path),把相对路径原样交给 /sidebar/file。
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 的地址语法无法表达相对路径(encodeHtmlUrl 先 path.split(/[\\/]+/).filter(s => s !== '') 丢掉前导 /,decodeHtmlUrl 再统一补成绝对路径),于是 chart.html 被当成 /chart.html(根目录)解析 → 400/403。
复现步骤 / Steps to reproduce
- 启动 DSH Web 0.1.5-rc.1 + dsh-better-sidebar 0.19.0,进入任意会话(cwd 记为
/home/me),工作区里放一张 chart.png;
- 从文件树(或聊天里的文件链接)点开该图片;
- 右侧栏空白 / 破图;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(两处,互补;已在本地实现并验证)
- 宿主:
/sidebar/file 接受工作区相对路径——相对路径 join 到 session cwd,绝对路径原样透传,之后仍走原有 ensureWorkspacePath 围栏(与 fs.read 语义对齐;客户端 cwd 未知时也成立,第三方 mediaUrl viewer 一并受益)。
- 客户端:
fileUrl(mediaUrl / 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/file;tests/media-url-relative.spec.ts 直接驱动客户端 URL 构造器):在 v0.19.0 checkout 上 npx vitest run → 16 passed;越界 ../、工作区外绝对路径、指向工作区外的软链接仍全部 403。
- 全量
vitest run(126 文件 / 1321 用例):37 个失败全部是依赖 node-pty 原生模块的环境性失败,git stash 去掉本补丁后失败数完全一致,无新增回归;tsc --noEmit、eslint 干净。
- 用补丁后源码
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 提供,修复后需硬刷新或重启宿主才生效。
提交前确认 / Pre-submit checklist
运行环境 / 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 根目录的相对路径:
src/client/resource-address.ts—fileAddressFor(sessionId, cwd, path):路径在 cwd 之下时返回sessionFileAddress(sessionId, normalized.slice(root.length + 1)),即dsh-resource://file/session/<sid>/<相对路径>(tests/resource-address.spec.ts也钉住了这个行为)。src/client/native/index.ts—fileParamsOf()直接return { path: address.path },于是 editor tab 的tab.path是相对路径(0.18 及以前 tab 里存的是绝对路径)。src/client/EditorHost.tsx—const mediaUrlOf = () => mediaUrl(scope, path),把相对路径原样交给/sidebar/file。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的地址语法无法表达相对路径(encodeHtmlUrl先path.split(/[\\/]+/).filter(s => s !== '')丢掉前导/,decodeHtmlUrl再统一补成绝对路径),于是chart.html被当成/chart.html(根目录)解析 → 400/403。复现步骤 / Steps to reproduce
/home/me),工作区里放一张chart.png;/sidebar/file?…&path=chart.png返回 400。期望行为 / Expected behavior
工作区内的图片 / PDF / 下载 / HTML 渲染与 Markdown、代码预览一样正常工作;工作区围栏(越界 403)保持不变。
建议修复 / Suggested fix(两处,互补;已在本地实现并验证)
/sidebar/file接受工作区相对路径——相对路径 join 到 session cwd,绝对路径原样透传,之后仍走原有ensureWorkspacePath围栏(与fs.read语义对齐;客户端 cwd 未知时也成立,第三方mediaUrlviewer 一并受益)。fileUrl(mediaUrl/downloadUrl)与htmlUrl统一用已有的resolveSidebarPath(cwd, path)解析后再编码——这正是changes/DiffPane.tsx现在对 html 预览做的处理;/sidebar/html只能靠客户端解析,因此这一处是必需的。展开完整源码 diff(src/index.ts + src/client/api.ts)
验证 / Verification
tests/media-relative-path.spec.ts用假 ctx 挂载真实路由驱动/sidebar/file;tests/media-url-relative.spec.ts直接驱动客户端 URL 构造器):在 v0.19.0 checkout 上npx vitest run→ 16 passed;越界../、工作区外绝对路径、指向工作区外的软链接仍全部 403。vitest run(126 文件 / 1321 用例):37 个失败全部是依赖 node-pty 原生模块的环境性失败,git stash去掉本补丁后失败数完全一致,无新增回归;tsc --noEmit、eslint干净。npx tsdown重建lib/,与手工落地的产物逐段比对:客户端paths.ts / html-route.ts / produced-files.ts / api.ts四段完全一致。补充信息 / Additional context
path换成绝对路径即可正常取图(见上方 B)。cache-control: public, max-age=31536000, immutable提供,修复后需硬刷新或重启宿主才生效。