Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/external-plugin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,8 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
```

> **`path` 的两种拼法都收**(修复见 [#618](https://github.com/omdsh-dev/DSH-better-sidebar/issues/618)):绝对路径原样使用;**工作区相对路径**会先 join 到该 session 的权威 `cwd` 再走围栏——原生文件地址 `dsh-resource://file/session/<sid>/<path>` 对工作区内的文件用的就是相对拼法(文件 tab 的 `tab.path` 因此是相对路径),`fs.read` 一直也是这个语义。`/sidebar/html` 没有这个宽松度:它的地址语法只能表达绝对路径(编码时丢掉前导 `/`、解码时统一补回),所以 **HTML 预览 URL 必须由调用方先解析成绝对路径**(内置实现见 `api.ts` 的 `htmlUrl`)。

> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。

---
Expand Down
93 changes: 93 additions & 0 deletions docs/plans/2026-09-10-media-relative-path-618.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 原生文件地址的相对拼法在媒体路由 400(#618)

日期:2026-09-10 分支:`fix/618-media-relative-path` issue:[#618](https://github.com/omdsh-dev/DSH-better-sidebar/issues/618)(作者 @longisland-icetea)

## 问题

v0.19.0 迁到 DSH 原生右侧栏后,从**聊天里点开的文件**如果是靠 `mediaUrl` 渲染的,右侧栏就是空白/破图:图片、PDF、二进制下载、`.html` 渲染模式,以及 markdown 预览里的**本地图片**(同一通道)。文本、代码、md 正文的文本渲染正常。

触发入口**只有聊天那一侧**:DSH 的聊天漏斗在建文件地址时会带上 session cwd,于是工作区内的文件被写成**相对 session 根**的拼法;而插件自己的文件打开(文件树 / 产物 chip / 引用)不带 scope cwd,地址里保留的是**绝对**拼法 —— 后者不触发。

实测(同一份文件、两条入口,直接跑插件自己的 `fileAddressFor` + `parseFileAddress`):

```
聊天打开(DSH 带 session cwd)
address : dsh-resource://file/session/s1/docs/screenshots/x.png
tab.path: docs/screenshots/x.png → 相对 → mediaUrl 400 ❌

文件树打开(插件 openTab 不带 scope)
address : dsh-resource://file/session/s1//Users/y/…/docs/screenshots/x.png
tab.path: /Users/y/…/docs/screenshots/x.png → 绝对 → mediaUrl 200 ✅
```

真机抓包(插件 0.19.0 + DSH 0.1.5-rc.1,Chromium Network 过滤 `sidebar/file`;同一会话、同一文件、同一次操作):

| 入口 | 请求里的 `path=` | 结果 |
|---|---|---|
| 正文里的蓝色文件链接(聊天漏斗) | `.sidebar-route-demo/demo.svg`(相对,无前导 `/`) | **破图** → 路由 400 |
| 同一回合「本轮文件改动」里的 chip(插件漏斗) | `/Users/y/workspace/dsh-better-sidebar/.sidebar-route-demo/demo.svg`(绝对) | **正常渲染** → 200 |

两条请求的 `sessionId` / `cwd` 完全相同,唯一变量就是路径拼法 —— 上面的地址推导在真机上闭环。

> 更正:issue #618 的复现步骤写作「从文件树(或聊天里的文件链接)点开该图片」,其中**文件树那半不成立**(真机抓包第二行即反例)——照那条步骤走复现不了;影响面也因此不是「工作区内任何图片 / PDF 都坏」,而是**从聊天入口点开的那些**。

### 复现配方(已在真机跑通)

- **UI(已真机跑通)**:让模型 `write` 一个 `.svg`(文本可写 → 进 `produced` → 收尾消息里出现可点的蓝色行内代码链接;而 `.svg` 被插件当图片渲染 → 必走 `mediaUrl`)→ 点那条蓝链接 → 预览破图,DevTools Network 里 `sidebar/file` 是 **400**,`path=` 为相对拼法(无前导 `/`)。再点同一回合「本轮文件改动」里同一文件的 chip → **正常渲染**(`path=` 带前导 `/`)。
- **curl**(issue 原文,仍成立):
```
GET /sidebar/file?sessionId=<sid>&path=chart.png&cwd=/home/me → 400 "chart.png" is not an absolute path
GET /sidebar/file?sessionId=<sid>&path=/home/me/chart.png&cwd=/home/me → 200 image/png
GET /sidebar/html/<sid>/chart.html → 400 cannot resolve target "/chart.html"
```

## 根因

发地址的一侧(DSH)与收地址的一侧(插件的媒体 / HTML 路由)对**同一份地址语法**的假定不一致:

1. `dsh-client-ui-chat` 的 `openFile` 注入:`fileAddressFor(sessionId, cwd, path)` —— **带** session cwd,工作区内的文件因此被相对化(`packages/util/workspace-path` 的 `fileAddressFor`:路径在 cwd 之下 → `sessionFileAddress(sessionId, 相对路径)`)。聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)都经这里。
2. 插件自己的打开:`src/client/service.ts:708` 用 `scope?.cwd` 建地址,而 `openSidebarFile()`(`src/client/intercept.tsx`)等调用点**不传 scope** → `cwd === undefined` → `fileAddressFor` 走 `root === ''` 分支,**保留绝对拼法**。这就是文件树不复现的原因。
3. `src/client/native/index.ts` 的 `fileParamsOf()` 把 `address.path` 原样交给 tab,于是 `tab.path` 就是地址里的拼法(0.18 及以前 tab 里恒为绝对路径)。
4. `src/client/EditorHost.tsx` 的 `mediaUrlOf = () => mediaUrl(scope, path)` 原样交给 `/sidebar/file` —— 该路由是全插件**唯一**硬性要求绝对路径的文件路由(`requireAbsolute`,`src/fs-tree.ts:129`);`fs.read` 走 `resolveGitPath()`,相对路径会被 join 到 session cwd,所以坏的只有媒体这条通道。
5. `/sidebar/html` 是同一根因的另一面,且**宿主无法补救**:`encodeHtmlUrl` 丢掉前导 `/`、`decodeHtmlUrl` 再统一补成绝对路径,相对拼法在该语法里不可表达(`chart.html` 被当成 `/chart.html`)。

上游最新(`@deepseek-ai/dsh-client-ui-deliverables` 等 0.1.5-rc.1)没有相关改动,issue 检索确认本仓没有别的 PR 在做这件事。

## 方案(两处,互补)

- **宿主**:新增 `resolveWorkspaceTarget(cwd, target)`(`src/path-security.ts`):绝对目标原样透传(工作区外的拼法交给围栏判定),相对目标 join 到 session 权威 cwd;`/sidebar/file` 在 `ensureWorkspacePath` 之前套用它。与 `fs.read` 的历史语义对齐,顺带覆盖「客户端 cwd 还没 hydrate」的窗口和第三方 `mediaUrl` viewer。
- **客户端**:`src/client/api.ts` 的 `fileUrl`(media / download)与 `htmlUrl` 统一经 `resolveSidebarPath(cwd, path)` 解析后再编码。/sidebar/html 只能靠这一处;媒体那处让 URL 在客户端 cwd 已知时就是绝对拼法。

围栏语义不变:join 之后仍走 `ensureWorkspacePath` 的 realpath + 包含检查(`../` 逃逸、工作区外绝对路径、指向外部的软链接照旧 403)。

## 归属与上游

触发点在**宿主**(聊天漏斗按相对拼法播地址),但插件侧容错是必须的:本仓硬约束不改 DSH 源码;而且相对拼法本就合法(`dsh-resource://file/session/<sid>/<path>` 对工作区内文件就是这么拼的),第三方 viewer 与「客户端 cwd 尚未 hydrate」的窗口同样会送相对拼法。可选的上游改法(DSH 无 issue 追踪,只能走 Discussions):聊天漏斗改送绝对拼法,或在地址契约里写明两种拼法都必须被消费方接受。

## 验证

- 新增 `tests/media-relative-path.spec.ts`(10 例,假 ctx 挂真实路由 + 临时工作区):相对路径无 cwd 参数 200、嵌套相对 200、绝对不变、`?download=1`、`../` 逃逸 403、工作区外绝对 403、缺失文件 400、软链接逃逸 403、**客户端 builder 产物直接喂给真实路由 200**、`htmlUrl` 产物喂给 `/sidebar/html` 200。把 src 三处改动 stash 掉后新用例 **17 例中 11 例失败**(宿主 7 + 客户端 4;剩下 6 例断言的是"绝对路径不变 / 无 cwd 时透传"这类既有行为)→ 用例确实咬住修复。
- 新增 `tests/media-url-relative.spec.ts`(7 例):相对路径 join、Windows 反斜杠 cwd、绝对/工作区外绝对不变、无 cwd 时原样透传(宿主兜底)、html 相对与绝对拼法产出同一 URL。
- **真机抓包**(DSH 0.1.5-rc.1 + 插件 0.19.0,Chromium):同一文件的两条请求 —— 相对拼法破图、绝对拼法正常,`sessionId` / `cwd` 相同、唯一变量是拼法。
- 地址拼法差异另用实测输出复核(`fileAddressFor` + `parseFileAddress`,两条入口各跑一次)。
- `pnpm typecheck` / `pnpm build` / 改动文件 `eslint` 全绿。
- 全量 `pnpm test`:**2 failed | 124 passed(29 例 `posix_spawnp failed`)**,与本机基线(stash 掉改动后跑同两个 pty 文件)**失败数完全一致**,属沙箱禁 pty 的环境性失败,无新增回归。

## 不做

- 不改 `markdown-images.ts`:它按 md 文件目录拼出的候选路径在 md 本身是相对拼法时也是相对拼法,本次宿主改动已覆盖。
- 不动 `/sidebar/html` 的地址语法(编码器改为可表达相对路径会破坏已发布 URL 的兼容性与相对资源解析语义)。

## 不覆盖(另有待办)

| 现象 | 为什么不在本次范围 |
|---|---|
| 正文里点 **presented(被 `present` 交付过)**的文件 → 在**宿主桌面**打开(远程访问时表现为「在 Mac 上弹出来」) | DSH `chatFileMentions` 的交付语义:命中 `deliveries` 就走 `POST /api/present.open`,与路径拼法无关。插件要接管需包装 `chatFileMentions` 服务值 + 设置开关 |
| 回合结束时「交付行」里 presented 文件的按钮消失 | 插件的 `conversation.chat.turnTail` 接管目前只认 `produced`,会把 DSH 原生 presented 那半边顶掉(另见 #390) |
| 正文里 **bash / 脚本生成**的文件路径不可点 | 它们不进 `produced`(DSH 只从 write/edit 的工具结果记路径)也不进 `presented`,除非被 `present` 过 |

## 影响面 / 提醒

- **受影响**:从**聊天**打开的文件 —— 图片 / PDF / 二进制下载 / `.html` 渲染模式(真机抓包已证);markdown 预览里的本地图片走同一条 `mediaUrl` 通道(同因,未单独抓包)。**不受影响**:从文件树 / 产物 chip 打开同一份文件(绝对拼法,200);文本 / 代码 / md 正文。
- 客户端包由宿主以 `cache-control: public, max-age=31536000, immutable` 供给,修复要**硬刷新或重启宿主**才在浏览器生效;宿主侧改动重启即生效。
- 0.19.0 + DSH 0.1.5-rc.1 默认组合受影响;0.18.1 不受影响(那时 tab 里恒为绝对路径)。
27 changes: 25 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -425,6 +426,28 @@ export const api = {
openExternal,
}

/**
* The path spelling the media and HTML routes take: absolute in the session's
* namespace.
*
* A file tab seeded by a native file address
* (`dsh-resource://file/session/<sid>/<path>`) carries an in-workspace path
* RELATIVE to the session root (see `resource-address.ts`), which the HTML
* route cannot express at all — its encoder drops a leading `/` and its
* decoder reads the segments back as an absolute path, so `chart.html` became
* `/chart.html` — and which the media route only accepts since the matching
* host fix (`resolveWorkspaceTarget` joins a relative target onto the session
* cwd). `resolveSidebarPath` returns an absolute path unchanged and leaves a
* relative one alone while the session cwd is not known yet, which the host
* then resolves.
* @param scope - the session scope carrying the cwd.
* @param path - a workspace-relative or absolute path.
* @returns the absolute path for the URL builders below.
*/
function sessionAbsolutePath(scope: SessionScope, path: string): string {
return resolveSidebarPath(scope.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)
Expand All @@ -438,7 +461,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: sessionAbsolutePath(scope, path) })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
Expand All @@ -453,5 +476,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, sessionAbsolutePath(scope, path))
}
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { resolveSessionPath } from './session-path.ts'
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath, resolveWorkspaceTarget } from './path-security.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
Expand Down Expand Up @@ -981,7 +981,11 @@ 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 resolveWorkspaceTarget); the fence
// still bounds the joined target.
const path = await ensureWorkspacePath(cwd, resolveWorkspaceTarget(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)
Expand Down
25 changes: 24 additions & 1 deletion src/path-security.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Filesystem path guards shared by sidebar APIs that access a session workspace. */
import { realpath } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { basename, dirname, isAbsolute, join } from 'node:path'
import { isWithin, requireAbsolute } from './fs-tree.ts'
import { resolveSessionPath } from './session-path.ts'
import { SidebarError } from './wire.ts'
Expand All @@ -21,6 +21,29 @@ function assertWithinWorkspace(workspace: string, target: string): void {
}
}

/**
* Resolve a client-supplied route target inside the session namespace: an
* absolute target passes through (including the outside-workspace spelling,
* which the fence judges afterwards), while a workspace-relative one joins
* the session cwd.
*
* The media route needs this because 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
* right-Sidebar migration the image / PDF / download viewers hand the route a
* workspace-relative path. The JSON APIs have always read their `path` field
* this way (`fs.read` → `resolveGitPath`), which is why only the media channel
* broke; joining here also covers a client that has not resolved a cwd yet and
* third-party viewers built on `ctx.betterSidebar`'s media URL.
*
* @param cwd - The session's authoritative working directory.
* @param target - The client-supplied path (workspace-relative or absolute).
* @returns An absolute path for `requireAbsolute` / `ensureWorkspacePath`.
*/
export function resolveWorkspaceTarget(cwd: string, target: string): string {
return isAbsolute(target) ? target : join(cwd, target)
}

/**
* Resolve an existing workspace path through symlinks and (unless disarmed)
* enforce containment.
Expand Down
Loading