From 064e8b8f9da8bee2e348658d2c0f005cbca0d5cc Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Thu, 10 Sep 2026 22:39:13 +0800 Subject: [PATCH 1/2] fix(media): accept workspace-relative paths in /sidebar/file (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the native right-Sidebar migration a file tab is seeded by a dsh-resource://file/session// address, which spells an in-workspace file RELATIVE to the session root. /sidebar/file was the only file route that demanded an absolute path (requireAbsolute -> 400), so every image / PDF / download viewer answered 400 and stayed blank, while the text channel kept working because fs.read has always joined a relative target onto the session cwd. /sidebar/html is the same root cause and cannot be fixed host-side (its encoder drops a leading /, its decoder rebuilds an absolute path), so the client must resolve before encoding. Host: resolveWorkspaceTarget() joins a relative target onto the session's authoritative cwd before ensureWorkspacePath, keeping the realpath fence — ../ escapes, outside absolute paths and outward symlinks still 403. Client: mediaUrl / downloadUrl / htmlUrl resolve through resolveSidebarPath(cwd, path) first; an absolute path is unchanged and a relative one without a cwd is left to the host. Two new specs (17 cases) drive the real routes through a fake context over a temporary workspace; stashing the source change fails 15 of them. Fixes #618. Root cause, reproduction requests and the client-side HTML constraint were reported by @longisland-icetea; see docs/plans/2026-09-10-media-relative-path-618.md. --- docs/external-plugin-guide.md | 2 + .../2026-09-10-media-relative-path-618.md | 75 +++++++ src/client/api.ts | 27 ++- src/index.ts | 8 +- src/path-security.ts | 25 ++- tests/media-relative-path.spec.ts | 188 ++++++++++++++++++ tests/media-url-relative.spec.ts | 68 +++++++ 7 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-09-10-media-relative-path-618.md create mode 100644 tests/media-relative-path.spec.ts create mode 100644 tests/media-url-relative.spec.ts diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 9e11cd7a5..51cb27faf 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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//` 对工作区内的文件用的就是相对拼法(文件 tab 的 `tab.path` 因此是相对路径),`fs.read` 一直也是这个语义。`/sidebar/html` 没有这个宽松度:它的地址语法只能表达绝对路径(编码时丢掉前导 `/`、解码时统一补回),所以 **HTML 预览 URL 必须由调用方先解析成绝对路径**(内置实现见 `api.ts` 的 `htmlUrl`)。 + > 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。 --- diff --git a/docs/plans/2026-09-10-media-relative-path-618.md b/docs/plans/2026-09-10-media-relative-path-618.md new file mode 100644 index 000000000..7386250a7 --- /dev/null +++ b/docs/plans/2026-09-10-media-relative-path-618.md @@ -0,0 +1,75 @@ +# 原生文件地址的相对拼法在媒体路由 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 ✅ +``` + +> 更正:issue #618 的复现步骤写作「从文件树(或聊天里的文件链接)点开该图片」,其中**文件树那半不成立**(上面第二行即反例)——照那条步骤走复现不了。 + +### 复现配方(可复制) + +- **UI**:让模型 `write` 一个 `.svg`(文本可写 → 进 `produced` → 收尾消息里出现可点的蓝色行内代码链接;而 `.svg` 被插件当图片渲染 → 必走 `mediaUrl`)→ 点那条蓝链接 → 预览空白,DevTools Network 里 `sidebar/file` 是 **400**,`path=` 为相对拼法(无前导 `/`)。同一份文件从**文件树**打开 → 正常(`path=` 带前导 `/`)。 +- **curl**(issue 原文,仍成立): + ``` + GET /sidebar/file?sessionId=&path=chart.png&cwd=/home/me → 400 "chart.png" is not an absolute path + GET /sidebar/file?sessionId=&path=/home/me/chart.png&cwd=/home/me → 200 image/png + GET /sidebar/html//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//` 对工作区内文件就是这么拼的),第三方 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。 +- 地址拼法差异用上面的实测输出复核(`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 的兼容性与相对资源解析语义)。 + +## 影响面 / 提醒 + +- **受影响**:从**聊天**打开的文件——图片 / 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 里恒为绝对路径)。 diff --git a/src/client/api.ts b/src/client/api.ts index 2c40f3628..f62ddca71 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,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//`) 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) @@ -434,7 +457,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()}` @@ -449,5 +472,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)) } diff --git a/src/index.ts b/src/index.ts index 8eba7c368..95c7a56e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -970,7 +970,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) diff --git a/src/path-security.ts b/src/path-security.ts index fb31027d0..7ab767d5b 100644 --- a/src/path-security.ts +++ b/src/path-security.ts @@ -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' @@ -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//`) 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. diff --git a/tests/media-relative-path.spec.ts b/tests/media-relative-path.spec.ts new file mode 100644 index 000000000..27653d309 --- /dev/null +++ b/tests/media-relative-path.spec.ts @@ -0,0 +1,188 @@ +/** + * Regression spec for #618 — the media route must accept the path spelling a + * native file address carries. + * + * Since the native right-Sidebar migration a file tab is seeded by a + * `dsh-resource://file/session//` address, which spells an + * IN-WORKSPACE file relative to the session root. `/sidebar/file` used to + * demand an absolute path (`requireAbsolute` → 400), so every image / PDF / + * download viewer opened from the file tree or the chat answered 400 and the + * pane stayed blank — while the text channel (`fs.read`) kept working because + * it has always joined a relative target onto the session cwd. + * + * These cases drive the REAL route through a fake context (the harness + * smoke.spec.ts uses) over a temporary workspace, and assert the workspace + * fence is intact: a relative target may only resolve INSIDE the session + * workspace. + */ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { apply } from '../src/index.ts' +import { htmlUrl, mediaUrl } from '../src/client/api.ts' +import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' + +/** PNG magic bytes: enough for the route, which serves bytes as they are. */ +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02]) + +/** + * One scratch workspace (`/ws`) plus a sibling directory outside it. + * Built at module scope because the route harness below mounts during + * collection — a `beforeAll` cwd would still be empty when the mount captured it. + */ +const root = mkdtempSync(join(tmpdir(), 'dsh-media-relative-')) +const ws = join(root, 'ws') +const outside = join(root, 'outside') +mkdirSync(join(ws, 'docs', 'img'), { recursive: true }) +mkdirSync(outside, { recursive: true }) +writeFileSync(join(ws, 'chart.png'), PNG) +writeFileSync(join(ws, 'docs', 'img', 'inline.png'), PNG) +writeFileSync(join(ws, 'docs', 'index.html'), '

ok

') +writeFileSync(join(outside, 'secret.png'), PNG) + +/** Whether this host may create symlinks (Windows CI often cannot). */ +const canSymlink = (() => { + const probe = mkdtempSync(join(tmpdir(), 'dsh-media-symlink-probe-')) + try { + writeFileSync(join(probe, 'target'), 'x') + symlinkSync(join(probe, 'target'), join(probe, 'link')) + return true + } catch { + return false + } finally { + rmSync(probe, { recursive: true, force: true }) + } +})() + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** The plugin mounted against a fake context; the session's cwd is `cwd`. */ +function mount(cwd: string): SidebarWebRoute[] { + const routes: SidebarWebRoute[] = [] + const ctx = { + webRuntime: { trustedHosts: [] }, + webServer: { + register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, + registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, + }, + sessions: { get: () => ({ header: { cwd } }) }, + tools: { register: () => () => {} }, + // The vendored cordis runs registration effects immediately. + effect: (fn: () => void | (() => void)) => { fn() }, + // No settings service in this context: the namespace registration never + // runs, so the workspace fence keeps its documented default (enabled). + inject: () => () => {}, + on: () => () => {}, + get: () => undefined, + } + apply(ctx as never) + return routes +} + +interface Reply { + status: number + headers: Record + body: Buffer +} + +/** Drive one mounted route with a GET. */ +async function get(routes: SidebarWebRoute[], path: string, url: string): Promise { + const route = routes.find(candidate => candidate.path === path) + if (route === undefined) throw new Error(`route ${path} is not mounted`) + const out: Reply = { status: 0, headers: {}, body: Buffer.alloc(0) } + const req = { method: 'GET', url, headers: { host: '127.0.0.1:3080' } } as never + const res = { + writeHead: (status: number, headers?: Record) => { + out.status = status + out.headers = headers ?? {} + }, + end: (chunk?: string | Buffer) => { + if (chunk !== undefined) out.body = Buffer.from(chunk) + }, + } as never + await route.handler(req, res) + return out +} + +/** A `/sidebar/file` URL with a raw (unencoded) query spelling. */ +function fileRoute(path: string, extra: Record = {}): string { + return `/sidebar/file?${new URLSearchParams({ sessionId: 's1', path, ...extra }).toString()}` +} + +/** The error code of a JSON error reply. */ +function errorCode(reply: Reply): string { + return (JSON.parse(reply.body.toString()) as { error: { code: string } }).error.code +} + +describe('/sidebar/file accepts a workspace-relative target (#618)', () => { + const routes = mount(ws) + + it('serves a workspace-relative path with no client cwd (the native-address case)', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute('chart.png')) + expect(reply.status).toBe(200) + expect(reply.headers['content-type']).toBe('image/png') + expect(reply.body).toEqual(PNG) + }) + + it('serves a nested workspace-relative path', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute('docs/img/inline.png', { cwd: ws })) + expect(reply.status).toBe(200) + expect(reply.body).toEqual(PNG) + }) + + it('keeps accepting an absolute path', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute(join(ws, 'chart.png'))) + expect(reply.status).toBe(200) + expect(reply.body).toEqual(PNG) + }) + + it('honours ?download=1 on a relative path', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute('chart.png', { download: '1' })) + expect(reply.status).toBe(200) + expect(reply.headers['content-disposition']).toBe("attachment; filename*=UTF-8''chart.png") + }) + + it('still fences a relative target that climbs out of the workspace', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute('../outside/secret.png')) + expect(reply.status).toBe(403) + expect(errorCode(reply)).toBe('forbidden') + }) + + it('still fences an absolute target outside the workspace', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute(join(outside, 'secret.png'))) + expect(reply.status).toBe(403) + expect(errorCode(reply)).toBe('forbidden') + }) + + it('rejects a missing relative path as an fs error, not a bad-request', async () => { + const reply = await get(routes, '/sidebar/file', fileRoute('nope.png')) + expect(reply.status).toBe(400) + expect(errorCode(reply)).toBe('fs-error') + }) + + it.runIf(canSymlink)('refuses a symlink whose real target is outside', async () => { + const link = join(ws, 'link.png') + symlinkSync(join(outside, 'secret.png'), link) + const reply = await get(routes, '/sidebar/file', fileRoute('link.png')) + expect(reply.status).toBe(403) + }) + + it('serves what the client media-URL builder produces from a native seed', async () => { + // The two halves of the fix must agree: the tab's path is the + // workspace-relative spelling the address carries, and the builder turns + // it into the URL the route above answers. + const reply = await get(routes, '/sidebar/file', mediaUrl({ sessionId: 's1', cwd: ws }, 'docs/img/inline.png')) + expect(reply.status).toBe(200) + expect(reply.body).toEqual(PNG) + }) + + it('serves the HTML route for the URL the html builder produces', async () => { + const reply = await get(routes, '/sidebar/html', htmlUrl({ sessionId: 's1', cwd: ws }, 'docs/index.html')) + expect(reply.status).toBe(200) + expect(reply.headers['content-type']).toBe('text/html; charset=utf-8') + expect(reply.body.toString()).toContain('ok') + }) +}) diff --git a/tests/media-url-relative.spec.ts b/tests/media-url-relative.spec.ts new file mode 100644 index 000000000..910c71d43 --- /dev/null +++ b/tests/media-url-relative.spec.ts @@ -0,0 +1,68 @@ +/** + * Client half of #618: the media / download / HTML URL builders must spell a + * file path the way their host routes can read it. + * + * A native file tab is seeded with a `dsh-resource://file/session//` + * address, whose in-workspace path is RELATIVE to the session root. Two things + * follow, both asserted here: + * + * - `/sidebar/html` cannot express a relative path at all (the encoder drops a + * leading `/` and the decoder rebuilds an absolute path, so `docs/index.html` + * came back as `/docs/index.html`), so the client must resolve against the + * session cwd — this half is load-bearing for the HTML previewer; + * - `/sidebar/file` reads an absolute path unchanged, and the host now joins a + * relative one onto the session cwd (see `tests/media-relative-path.spec.ts`), + * which keeps the media URL correct while the session summary is still + * hydrating and the client has no cwd yet. + */ +import { describe, expect, it } from 'vitest' +import { downloadUrl, htmlUrl, mediaUrl } from '../src/client/api.ts' + +/** The `path` query parameter of a builder result. */ +function pathParam(url: string): string { + return new URL(url, 'http://dsh.internal').searchParams.get('path') ?? '' +} + +describe('session-scoped media URLs resolve a native (relative) seed', () => { + it('joins a workspace-relative path onto the session cwd', () => { + const url = mediaUrl({ sessionId: 's1', cwd: '/home/me' }, 'chart.png') + expect(pathParam(url)).toBe('/home/me/chart.png') + expect(new URL(url, 'http://dsh.internal').searchParams.get('cwd')).toBe('/home/me') + }) + + it('resolves a nested relative path and keeps the download flag', () => { + const url = downloadUrl({ sessionId: 's1', cwd: '/home/me' }, 'docs/img/inline.png') + expect(pathParam(url)).toBe('/home/me/docs/img/inline.png') + expect(new URL(url, 'http://dsh.internal').searchParams.get('download')).toBe('1') + }) + + it('leaves an absolute path untouched (absolute or outside the workspace)', () => { + expect(pathParam(mediaUrl({ sessionId: 's1', cwd: '/home/me' }, '/home/me/chart.png'))).toBe('/home/me/chart.png') + expect(pathParam(mediaUrl({ sessionId: 's1', cwd: '/home/me' }, '/elsewhere/x.png'))).toBe('/elsewhere/x.png') + }) + + it('joins with the cwd own separator style (Windows sessions)', () => { + expect(pathParam(mediaUrl({ sessionId: 's1', cwd: 'C:\\work' }, 'chart.png'))).toBe('C:\\work\\chart.png') + }) + + it('passes the seed through unchanged while the session cwd is unknown', () => { + // The host joins it onto the session's authoritative cwd; the client only + // knows the summary, which hydrates a moment later. + const url = mediaUrl({ sessionId: 's1' }, 'chart.png') + expect(pathParam(url)).toBe('chart.png') + expect(new URL(url, 'http://dsh.internal').searchParams.get('cwd')).toBeNull() + }) + + it('builds an HTML route URL the host decodes back to the absolute path', () => { + expect(htmlUrl({ sessionId: 's1', cwd: '/home/me' }, 'docs/index.html')).toBe('/sidebar/html/s1/home/me/docs/index.html') + // The same file spelled absolutely produces the same route. + expect(htmlUrl({ sessionId: 's1', cwd: '/home/me' }, '/home/me/docs/index.html')).toBe('/sidebar/html/s1/home/me/docs/index.html') + }) + + it('never lets a cwd-less relative seed masquerade as a root-absolute one', () => { + // Documented limitation of the pre-hydration window: without a cwd the + // HTML route cannot know the session root (its grammar is absolute-only), + // so the preview must wait for the summary rather than fetch `/docs/...`. + expect(htmlUrl({ sessionId: 's1' }, 'docs/index.html')).toBe('/sidebar/html/s1/docs/index.html') + }) +}) From 295615abbd209ee7947db05da92ee60b09f9bacd Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Fri, 11 Sep 2026 00:09:14 +0800 Subject: [PATCH 2/2] docs(plans): record the #618 real-machine capture (relative vs absolute path) --- .../2026-09-10-media-relative-path-618.md | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-09-10-media-relative-path-618.md b/docs/plans/2026-09-10-media-relative-path-618.md index 7386250a7..e92dc457c 100644 --- a/docs/plans/2026-09-10-media-relative-path-618.md +++ b/docs/plans/2026-09-10-media-relative-path-618.md @@ -20,11 +20,20 @@ v0.19.0 迁到 DSH 原生右侧栏后,从**聊天里点开的文件**如果是 tab.path: /Users/y/…/docs/screenshots/x.png → 绝对 → mediaUrl 200 ✅ ``` -> 更正:issue #618 的复现步骤写作「从文件树(或聊天里的文件链接)点开该图片」,其中**文件树那半不成立**(上面第二行即反例)——照那条步骤走复现不了。 +真机抓包(插件 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 | -- **UI**:让模型 `write` 一个 `.svg`(文本可写 → 进 `produced` → 收尾消息里出现可点的蓝色行内代码链接;而 `.svg` 被插件当图片渲染 → 必走 `mediaUrl`)→ 点那条蓝链接 → 预览空白,DevTools Network 里 `sidebar/file` 是 **400**,`path=` 为相对拼法(无前导 `/`)。同一份文件从**文件树**打开 → 正常(`path=` 带前导 `/`)。 +两条请求的 `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=&path=chart.png&cwd=/home/me → 400 "chart.png" is not an absolute path @@ -59,7 +68,8 @@ v0.19.0 迁到 DSH 原生右侧栏后,从**聊天里点开的文件**如果是 - 新增 `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。 -- 地址拼法差异用上面的实测输出复核(`fileAddressFor` + `parseFileAddress`,两条入口各跑一次)。 +- **真机抓包**(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 的环境性失败,无新增回归。 @@ -68,8 +78,16 @@ v0.19.0 迁到 DSH 原生右侧栏后,从**聊天里点开的文件**如果是 - 不改 `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 正文。 +- **受影响**:从**聊天**打开的文件 —— 图片 / 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 里恒为绝对路径)。